Преглед на файлове

Add local to docker apps

Toby Chui преди 11 часа
родител
ревизия
19918cf35b

+ 113 - 93
src/web/ContainerApps/index.html

@@ -8,6 +8,7 @@
     <link rel="stylesheet" href="../script/semantic/semantic.min.css">
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <script src="../script/applocale.js"></script>
     <title>Container Apps</title>
     <style>
         :root {
@@ -128,12 +129,12 @@
 <body>
 <div class="ca-app">
     <div class="ca-top">
-        <div class="ca-title"><img src="img/icon.svg" alt=""> <span>Container Apps</span></div>
+        <div class="ca-title"><img src="img/icon.svg" alt=""> <span locale="app/title">Container Apps</span></div>
         <div class="ca-seg hidden adminOnly" id="viewSwitch">
-            <button data-view="apps" class="active">Apps</button>
-            <button data-view="manage">Manage</button>
+            <button data-view="apps" class="active" locale="view/apps">Apps</button>
+            <button data-view="manage" locale="view/manage">Manage</button>
         </div>
-        <button class="ca-btn primary hidden adminOnly" id="newBtn"><i class="plus icon"></i> Publish app</button>
+        <button class="ca-btn primary hidden adminOnly" id="newBtn"><i class="plus icon"></i> <span locale="editor/publish">Publish app</span></button>
     </div>
 
     <div class="ca-body">
@@ -142,7 +143,7 @@
             <div class="ca-grid" id="appGrid"></div>
             <div class="ca-empty hidden" id="appEmpty">
                 <i class="cube icon"></i><br>
-                <span id="appEmptyText">No container apps have been published yet.</span>
+                <span id="appEmptyText" locale="apps/none">No container apps have been published yet.</span>
             </div>
         </section>
 
@@ -150,18 +151,18 @@
         <section id="view-manage" class="hidden">
             <div class="ca-card">
                 <div class="ca-card-head">
-                    <h3>Published apps</h3>
+                    <h3 locale="manage/published">Published apps</h3>
                     <button class="ca-btn sm" id="refreshBtn" title="Refresh"><i class="sync icon"></i></button>
                 </div>
                 <table class="ca-table">
-                    <thead><tr><th>App</th><th>Access</th><th>Traffic</th><th></th></tr></thead>
+                    <thead><tr><th locale="manage/app">App</th><th locale="manage/access">Access</th><th locale="manage/traffic">Traffic</th><th></th></tr></thead>
                     <tbody id="endpointBody"></tbody>
                 </table>
             </div>
             <div class="ca-card" id="containerCard">
-                <div class="ca-card-head"><h3>Containers with web ports</h3></div>
+                <div class="ca-card-head"><h3 locale="manage/containers">Containers with web ports</h3></div>
                 <table class="ca-table">
-                    <thead><tr><th>Container</th><th>Published ports</th><th></th></tr></thead>
+                    <thead><tr><th locale="manage/container">Container</th><th locale="manage/ports">Published ports</th><th></th></tr></thead>
                     <tbody id="containerBody"></tbody>
                 </table>
             </div>
@@ -173,107 +174,107 @@
 <div class="ca-shade hidden" id="shade"></div>
 <div class="ca-drawer hidden" id="editor">
     <div class="ca-drawer-head">
-        <h2 id="editorTitle">Publish app</h2>
+        <h2 id="editorTitle" locale="editor/publish">Publish app</h2>
         <button class="ca-btn sm" id="closeEditor" title="Close"><i class="close icon"></i></button>
     </div>
     <div class="ca-drawer-body">
         <div class="ca-section">
-            <div class="ca-section-title"><i class="server icon"></i> Web server</div>
+            <div class="ca-section-title"><i class="server icon"></i> <span locale="editor/webServer">Web server</span></div>
             <div class="ca-field">
-                <label for="fTarget">Target address</label>
+                <label for="fTarget" locale="editor/target">Target address</label>
                 <div class="ca-row">
                     <input class="ca-input ca-mono" id="fTarget" placeholder="127.0.0.1:8081" autocomplete="off">
-                    <button class="ca-btn" id="probeBtn" style="flex:0 0 auto;"><i class="search icon"></i> Analyze</button>
+                    <button class="ca-btn" id="probeBtn" style="flex:0 0 auto;"><i class="search icon"></i> <span locale="editor/analyze">Analyze</span></button>
                 </div>
-                <div class="ca-hint" id="targetHint">The host:port ArozOS reaches the web interface on, usually a port published by the container.</div>
+                <div class="ca-hint" id="targetHint" locale="editor/targetHint">The host:port ArozOS reaches the web interface on, usually a port published by the container.</div>
             </div>
-            <label class="ca-check"><input type="checkbox" id="fTargetTLS"><div><b>The app itself uses HTTPS</b><span>Only when the container serves https on this port.</span></div></label>
-            <label class="ca-check hidden" id="skipVerifyRow"><input type="checkbox" id="fSkipVerify"><div><b>Accept a self-signed certificate</b></div></label>
+            <label class="ca-check"><input type="checkbox" id="fTargetTLS"><div><b locale="editor/tls">The app itself uses HTTPS</b><span locale="editor/tlsHint">Only when the container serves https on this port.</span></div></label>
+            <label class="ca-check hidden" id="skipVerifyRow"><input type="checkbox" id="fSkipVerify"><div><b locale="editor/skipVerify">Accept a self-signed certificate</b></div></label>
             <div id="probeResult" class="hidden"></div>
         </div>
 
         <div class="ca-section">
-            <div class="ca-section-title"><i class="tag icon"></i> App</div>
+            <div class="ca-section-title"><i class="tag icon"></i> <span locale="editor/app">App</span></div>
             <div class="ca-row">
-                <div class="ca-field"><label for="fName">Name</label><input class="ca-input" id="fName" maxlength="64" autocomplete="off"></div>
-                <div class="ca-field"><label for="fSlug">Address name</label><input class="ca-input ca-mono" id="fSlug" maxlength="63" autocomplete="off" placeholder="my-app"></div>
+                <div class="ca-field"><label for="fName" locale="editor/name">Name</label><input class="ca-input" id="fName" maxlength="64" autocomplete="off"></div>
+                <div class="ca-field"><label for="fSlug" locale="editor/slug">Address name</label><input class="ca-input ca-mono" id="fSlug" maxlength="63" autocomplete="off" placeholder="my-app"></div>
             </div>
             <div class="ca-hint" id="slugHint" style="margin:-6px 0 10px;"></div>
             <div class="ca-field">
-                <label for="fIcon">Icon</label>
+                <label for="fIcon" locale="editor/icon">Icon</label>
                 <input class="ca-input" id="fIcon" placeholder="ContainerApps/img/app.svg" autocomplete="off">
-                <div class="ca-hint">A web path relative to the ArozOS root, or an image URL.</div>
+                <div class="ca-hint" locale="editor/iconHint">A web path relative to the ArozOS root, or an image URL.</div>
             </div>
         </div>
 
         <div class="ca-section">
-            <div class="ca-section-title"><i class="linkify icon"></i> How it is served</div>
+            <div class="ca-section-title"><i class="linkify icon"></i> <span locale="editor/served">How it is served</span></div>
             <div class="ca-modes">
                 <div class="ca-mode" data-mode="path">
-                    <b><i class="folder outline icon"></i> Path</b>
+                    <b><i class="folder outline icon"></i> <span locale="editor/path">Path</span></b>
                     <span id="modePathDesc">On this address: <span class="ca-mono">/app/&lt;name&gt;/</span>. Nothing to set up.</span>
                 </div>
                 <div class="ca-mode" data-mode="subdomain">
-                    <b><i class="globe icon"></i> Subdomain</b>
-                    <span>On its own host name, e.g. <span class="ca-mono">app.example.com</span>. Isolated from ArozOS.</span>
+                    <b><i class="globe icon"></i> <span locale="editor/subdomain">Subdomain</span></b>
+                    <span locale="editor/subdomainDesc">On its own host name, e.g. <span class="ca-mono">app.example.com</span>. Isolated from ArozOS.</span>
                 </div>
             </div>
 
             <div id="pathOptions">
                 <div class="ca-note warn">
                     <i class="exclamation triangle icon"></i>
-                    In path mode the app shares the ArozOS address. Its web pages can act as the signed in user on ArozOS,
-                    so only use it for software you trust. Choose subdomain mode for anything else.
+                    <span locale="editor/pathWarn">In path mode the app shares the ArozOS address. Its web pages can act as the signed in user on ArozOS,
+                    so only use it for software you trust. Choose subdomain mode for anything else.</span>
                 </div>
-                <label class="ca-check"><input type="checkbox" id="fTrusted"><div><b>I trust this container</b><span>Required for path mode.</span></div></label>
-                <label class="ca-check"><input type="checkbox" id="fRootRedirect"><div><b>Catch root requests</b><span>When the app asks for a file from "/", send it back to the app instead of the ArozOS web root.</span></div></label>
-                <label class="ca-check"><input type="checkbox" id="fRewriteRoot"><div><b>Rewrite root paths (inject script)</b><span>Rewrites links in pages and style sheets and injects a small script that routes the app's own requests. Needed by most apps that were built to run at "/".</span></div></label>
+                <label class="ca-check"><input type="checkbox" id="fTrusted"><div><b locale="editor/trusted">I trust this container</b><span locale="editor/trustedHint">Required for path mode.</span></div></label>
+                <label class="ca-check"><input type="checkbox" id="fRootRedirect"><div><b locale="editor/rootRedirect">Catch root requests</b><span locale="editor/rootRedirectHint">When the app asks for a file from "/", send it back to the app instead of the ArozOS web root.</span></div></label>
+                <label class="ca-check"><input type="checkbox" id="fRewriteRoot"><div><b locale="editor/rewrite">Rewrite root paths (inject script)</b><span locale="editor/rewriteHint">Rewrites links in pages and style sheets and injects a small script that routes the app's own requests. Needed by most apps that were built to run at "/".</span></div></label>
             </div>
 
             <div id="subdomainOptions">
                 <div class="ca-field">
-                    <label for="fHostname">Host name</label>
+                    <label for="fHostname" locale="editor/hostname">Host name</label>
                     <input class="ca-input ca-mono" id="fHostname" placeholder="app.example.com" autocomplete="off">
-                    <div class="ca-hint">Point this name at the same server as ArozOS (a DNS record, or a public hostname in Cloudflare Tunnel). Cloudflare's free certificate only covers one level, so prefer <span class="ca-mono">app.example.com</span> or <span class="ca-mono">app-nas.example.com</span> over <span class="ca-mono">app.nas.example.com</span>.</div>
+                    <div class="ca-hint" locale="editor/hostnameHint">Point this name at the same server as ArozOS (a DNS record, or a public hostname in Cloudflare Tunnel). Cloudflare's free certificate only covers one level, so prefer <span class="ca-mono">app.example.com</span> or <span class="ca-mono">app-nas.example.com</span> over <span class="ca-mono">app.nas.example.com</span>.</div>
                 </div>
                 <div class="ca-field">
-                    <label for="fPortal">ArozOS address</label>
+                    <label for="fPortal" locale="editor/portal">ArozOS address</label>
                     <input class="ca-input ca-mono" id="fPortal" placeholder="https://nas.example.com" autocomplete="off">
-                    <div class="ca-hint">Where users sign in before being sent to the app.</div>
+                    <div class="ca-hint" locale="editor/portalHint">Where users sign in before being sent to the app.</div>
                 </div>
-                <div class="ca-note warn hidden" id="ipWarning"><i class="exclamation triangle icon"></i> You are using ArozOS through an IP address. Subdomain mode needs a domain name.</div>
+                <div class="ca-note warn hidden" id="ipWarning"><i class="exclamation triangle icon"></i> <span locale="editor/ipWarning">You are using ArozOS through an IP address. Subdomain mode needs a domain name.</span></div>
             </div>
-            <label class="ca-check"><input type="checkbox" id="fAllowFraming"><div><b>Allow in desktop window</b><span>Remove the app's "do not frame me" headers so it can open in a desktop window.</span></div></label>
+            <label class="ca-check"><input type="checkbox" id="fAllowFraming"><div><b locale="editor/framing">Allow in desktop window</b><span locale="editor/framingHint">Remove the app's "do not frame me" headers so it can open in a desktop window.</span></div></label>
         </div>
 
         <div class="ca-section">
-            <div class="ca-section-title"><i class="users icon"></i> Who can open it</div>
-            <label class="ca-check"><input type="checkbox" id="fRequireLogin"><div><b>Require ArozOS sign in</b><span>Turn off only for apps meant to be public, for example a status page.</span></div></label>
+            <div class="ca-section-title"><i class="users icon"></i> <span locale="editor/who">Who can open it</span></div>
+            <label class="ca-check"><input type="checkbox" id="fRequireLogin"><div><b locale="editor/requireLogin">Require ArozOS sign in</b><span locale="editor/requireLoginHint">Turn off only for apps meant to be public, for example a status page.</span></div></label>
             <div class="ca-field" id="groupField">
-                <label>Permission groups</label>
+                <label locale="editor/groups">Permission groups</label>
                 <div class="ca-groups" id="groupList"></div>
-                <div class="ca-hint">None selected means every user can see it and pin it to their desktop.</div>
+                <div class="ca-hint" locale="editor/groupsHint">None selected means every user can see it and pin it to their desktop.</div>
             </div>
         </div>
 
         <div class="ca-section">
-            <div class="ca-section-title"><i class="window restore outline icon"></i> Opening from the desktop</div>
+            <div class="ca-section-title"><i class="window restore outline icon"></i> <span locale="editor/opening">Opening from the desktop</span></div>
             <div class="ca-row">
                 <div class="ca-field">
-                    <label for="fOpenIn">Open in</label>
-                    <select class="ca-input" id="fOpenIn"><option value="float">Desktop window</option><option value="tab">New browser tab</option></select>
+                    <label for="fOpenIn" locale="editor/openIn">Open in</label>
+                    <select class="ca-input" id="fOpenIn"><option value="float" locale="editor/openFloat">Desktop window</option><option value="tab" locale="editor/openTab">New browser tab</option></select>
                 </div>
-                <div class="ca-field"><label for="fWidth">Width</label><input class="ca-input" type="number" id="fWidth" min="100" max="10000" placeholder="1080"></div>
-                <div class="ca-field"><label for="fHeight">Height</label><input class="ca-input" type="number" id="fHeight" min="100" max="10000" placeholder="640"></div>
+                <div class="ca-field"><label for="fWidth" locale="editor/width">Width</label><input class="ca-input" type="number" id="fWidth" min="100" max="10000" placeholder="1080"></div>
+                <div class="ca-field"><label for="fHeight" locale="editor/height">Height</label><input class="ca-input" type="number" id="fHeight" min="100" max="10000" placeholder="640"></div>
             </div>
         </div>
     </div>
     <div class="ca-drawer-foot">
-        <button class="ca-btn danger hidden" id="deleteBtn"><i class="trash alternate outline icon"></i> Remove</button>
+        <button class="ca-btn danger hidden" id="deleteBtn"><i class="trash alternate outline icon"></i> <span locale="editor/remove">Remove</span></button>
         <div class="spacer"></div>
         <span class="ca-error" id="saveError"></span>
-        <button class="ca-btn" id="cancelBtn">Cancel</button>
-        <button class="ca-btn primary" id="saveBtn"><i class="check icon"></i> Save</button>
+        <button class="ca-btn" id="cancelBtn" locale="common/cancel">Cancel</button>
+        <button class="ca-btn primary" id="saveBtn"><i class="check icon"></i> <span locale="common/save">Save</span></button>
     </div>
 </div>
 
@@ -295,6 +296,14 @@
     var DEFAULT_ICON = "ContainerApps/img/app.svg";
     var state = {isAdmin: false, apps: [], endpoints: [], containers: [], groups: [], editing: null, slugTouched: false, mode: "path"};
 
+    //Localized string (English fallback) with {name} placeholders filled from vars
+    function T(key, fallback, vars){
+        var s = (typeof applocale !== "undefined" && applocale) ? applocale.getString(key, fallback) : fallback;
+        if (vars){
+            Object.keys(vars).forEach(function(k){ s = s.split("{" + k + "}").join(vars[k]); });
+        }
+        return s;
+    }
     function esc(s){
         return String(s == null ? "" : s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
     }
@@ -352,13 +361,13 @@
         var grid = $("#appGrid").empty();
         $("#appEmpty").toggleClass("hidden", state.apps.length > 0);
         $("#appEmptyText").text(state.isAdmin
-            ? "No container apps have been published yet. Use Publish app, or the Manage tab to publish a running container."
-            : "No container apps have been published for you yet.");
+            ? T("apps/emptyAdmin", "No container apps have been published yet. Use Publish app, or the Manage tab to publish a running container.")
+            : T("apps/emptyUser", "No container apps have been published for you yet."));
         state.apps.forEach(function(app){
             var tile = $('<div class="ca-tile" tabindex="0"></div>');
             tile.append($('<img alt="">').attr("src", iconSrc(app.Icon)).on("error", function(){ this.src = iconSrc(DEFAULT_ICON); }));
             tile.append($('<div class="name"></div>').text(app.Name));
-            var pin = $('<button class="ca-btn sm pin" title="Add to desktop"><i class="thumbtack icon"></i></button>');
+            var pin = $('<button class="ca-btn sm pin"><i class="thumbtack icon"></i></button>').attr("title", T("apps/pin", "Add to desktop"));
             pin.on("click", function(e){ e.stopPropagation(); pinApp(app); });
             tile.append(pin);
             tile.on("click", function(){ openApp(app); });
@@ -384,16 +393,16 @@
             sdest: "user:/Desktop/"
         }, function(data){
             if (data.error !== undefined){
-                toast("Unable to add to desktop: " + data.error, false);
+                toast(T("apps/pinFailedErr", "Unable to add to desktop: {error}", {error: data.error}), false);
                 return;
             }
-            toast(app.Name + " added to your desktop", true);
+            toast(T("apps/pinned", "{name} added to your desktop", {name: app.Name}), true);
             try {
                 if (ao_module_virtualDesktop && typeof parent.refresh === "function"){
                     parent.refresh();
                 }
             } catch (e) {}
-        }).fail(function(){ toast("Unable to add to desktop", false); });
+        }).fail(function(){ toast(T("apps/pinFailed", "Unable to add to desktop"), false); });
     }
 
     /* ---------------- Administrators ---------------- */
@@ -412,7 +421,7 @@
     function loadManage(done){
         var pending = 2;
         function finish(){ if (--pending === 0 && done){ done(); } }
-        $("#endpointBody").html('<tr><td colspan="4" class="ca-empty" style="padding:20px;">Loading...</td></tr>');
+        $("#endpointBody").html('<tr><td colspan="4" class="ca-empty" style="padding:20px;">' + T("common/loading", "Loading...") + '</td></tr>');
         $.get(API + "admin/list", function(list){
             state.endpoints = Array.isArray(list) ? list : [];
             renderEndpoints();
@@ -430,15 +439,15 @@
         }else{
             h += '<span class="pill blue"><i class="folder outline icon"></i>/app/' + esc(ep.Slug) + '/</span>';
             if (ep.RewriteRoot){
-                h += '<span class="pill">rewrite</span>';
+                h += '<span class="pill">' + T("manage/rewrite", "rewrite") + '</span>';
             }
         }
         if (!ep.RequireLogin){
-            h += '<span class="pill warn">public</span>';
+            h += '<span class="pill warn">' + T("manage/public", "public") + '</span>';
         }else if (ep.AllowedGroups && ep.AllowedGroups.length){
             h += '<span class="pill">' + esc(ep.AllowedGroups.join(", ")) + '</span>';
         }else{
-            h += '<span class="pill green">all users</span>';
+            h += '<span class="pill green">' + T("manage/allUsers", "all users") + '</span>';
         }
         return h;
     }
@@ -446,24 +455,24 @@
     function renderEndpoints(){
         var body = $("#endpointBody").empty();
         if (state.endpoints.length === 0){
-            body.html('<tr><td colspan="4" class="ca-empty" style="padding:24px;">Nothing published yet.</td></tr>');
+            body.html('<tr><td colspan="4" class="ca-empty" style="padding:24px;">' + T("manage/nothing", "Nothing published yet.") + '</td></tr>');
             return;
         }
         state.endpoints.forEach(function(ep){
             var s = ep.Stats || {};
-            var traffic = '<div>' + (s.Requests || 0) + ' requests</div>';
+            var traffic = '<div>' + T("manage/requests", "{n} requests", {n: s.Requests || 0}) + '</div>';
             if (ep.Mode === "path" && s.Leaks > 0){
-                var tip = "Recent: " + (s.LastLeaks || []).join(", ");
-                traffic += '<div class="ca-hint" title="' + esc(tip) + '"><i class="exclamation triangle icon"></i>' + s.Leaks + ' root requests caught' +
-                    (ep.RewriteRoot ? '' : ', consider rewriting') + '</div>';
+                var tip = T("manage/recent", "Recent: {list}", {list: (s.LastLeaks || []).join(", ")});
+                traffic += '<div class="ca-hint" title="' + esc(tip) + '"><i class="exclamation triangle icon"></i>' + T("manage/leaks", "{n} root requests caught", {n: s.Leaks}) +
+                    (ep.RewriteRoot ? '' : T("manage/considerRewrite", ", consider rewriting")) + '</div>';
             }
             var row = $('<tr>' +
                 '<td><div class="ca-appcell"><img alt=""><div style="min-width:0;"><div><b></b></div><div class="sub ca-mono"></div></div></div></td>' +
                 '<td>' + accessPills(ep) + '</td>' +
                 '<td>' + traffic + '</td>' +
                 '<td><div class="ca-actions">' +
-                    '<button class="ca-btn sm" data-act="open" title="Open"><i class="external alternate icon"></i></button>' +
-                    '<button class="ca-btn sm" data-act="edit" title="Edit"><i class="edit outline icon"></i></button>' +
+                    '<button class="ca-btn sm" data-act="open" title="' + esc(T("manage/open", "Open")) + '"><i class="external alternate icon"></i></button>' +
+                    '<button class="ca-btn sm" data-act="edit" title="' + esc(T("manage/edit", "Edit")) + '"><i class="edit outline icon"></i></button>' +
                 '</div></td></tr>');
             row.find("img").attr("src", iconSrc(ep.Icon)).on("error", function(){ this.src = iconSrc(DEFAULT_ICON); });
             row.find("b").text(ep.Name);
@@ -479,14 +488,14 @@
         var withPorts = state.containers.filter(function(c){ return c.Targets && c.Targets.length > 0; });
         if (withPorts.length === 0){
             body.html('<tr><td colspan="3" class="ca-empty" style="padding:24px;">' +
-                (state.containers.length === 0 ? "Docker is not available, or there are no containers." : "No container publishes a port on this host.") + '</td></tr>');
+                (state.containers.length === 0 ? T("manage/noDocker", "Docker is not available, or there are no containers.") : T("manage/noPorts", "No container publishes a port on this host.")) + '</td></tr>');
             return;
         }
         withPorts.forEach(function(c){
             var ports = c.Ports.map(function(p){
-                return '<span class="pill ca-mono" title="container port ' + p.ContainerPort + '">' + esc(c.Targets[c.Ports.indexOf(p)]) + '</span>';
+                return '<span class="pill ca-mono" title="' + esc(T("manage/containerPort", "container port {port}", {port: p.ContainerPort})) + '">' + esc(c.Targets[c.Ports.indexOf(p)]) + '</span>';
             }).join("");
-            var published = (c.Published || []).length ? '<div class="ca-hint">Published as ' + esc(c.Published.join(", ")) + '</div>' : "";
+            var published = (c.Published || []).length ? '<div class="ca-hint">' + esc(T("manage/publishedAs", "Published as {list}", {list: c.Published.join(", ")})) + '</div>' : "";
             var running = String(c.State).toLowerCase() === "running";
             var row = $('<tr>' +
                 '<td><div class="ca-mono"><b></b></div><div class="ca-hint ca-mono img"></div></td>' +
@@ -496,7 +505,7 @@
             row.find(".img").text(c.Image + (running ? "" : "  (" + c.State + ")"));
             var actions = row.find(".ca-actions");
             c.Targets.forEach(function(target, i){
-                var label = c.Targets.length > 1 ? "Publish :" + c.Ports[i].HostPort : "Publish";
+                var label = c.Targets.length > 1 ? T("manage/publishPort", "Publish :{port}", {port: c.Ports[i].HostPort}) : T("manage/publish", "Publish");
                 var btn = $('<button class="ca-btn sm primary"><i class="plus icon"></i> </button>');
                 btn.append(document.createTextNode(label));
                 btn.on("click", function(){ publishContainer(c, target); });
@@ -519,7 +528,7 @@
 
     /* ---------------- Editor ---------------- */
     function loadGroups(selected){
-        var list = $("#groupList").html('<span class="ca-hint">Loading...</span>');
+        var list = $("#groupList").html('<span class="ca-hint">' + T("common/loading", "Loading...") + '</span>');
         $.get("../system/permission/listgroup", function(groups){
             state.groups = Array.isArray(groups) ? groups : [];
             list.empty();
@@ -529,7 +538,7 @@
                 lbl.find("span").text(g);
                 list.append(lbl);
             });
-        }).fail(function(){ list.html('<span class="ca-hint">Unable to load groups</span>'); });
+        }).fail(function(){ list.html('<span class="ca-hint">' + T("editor/groupsFailed", "Unable to load groups") + '</span>'); });
     }
 
     function setMode(mode){
@@ -558,8 +567,8 @@
 
     function updateSlugHint(){
         var slug = $("#fSlug").val().trim();
-        $("#slugHint").html(slug ? 'Opens at <span class="ca-mono">' + esc(location.origin) + '/app/' + esc(slug) + '/</span>' : "");
-        $("#modePathDesc").html('On this address: <span class="ca-mono">/app/' + esc(slug || "<name>") + '/</span>. Nothing to set up.');
+        $("#slugHint").html(slug ? T("editor/opensAt", "Opens at {url}", {url: '<span class="ca-mono">' + esc(location.origin) + '/app/' + esc(slug) + '/</span>'}) : "");
+        $("#modePathDesc").html(T("editor/pathDesc", "On this address: {path}. Nothing to set up.", {path: '<span class="ca-mono">/app/' + esc(slug || T("editor/namePlaceholder", "<name>")) + '/</span>'}));
     }
 
     function openEditor(ep, prefill){
@@ -573,7 +582,7 @@
         state.prefill = d;
         state.slugTouched = !!ep;
 
-        $("#editorTitle").text(ep ? "Edit " + ep.Name : "Publish app");
+        $("#editorTitle").text(ep ? T("editor/editTitle", "Edit {name}", {name: ep.Name}) : T("editor/publish", "Publish app"));
         $("#fTarget").val(d.Target);
         $("#fTargetTLS").prop("checked", d.TargetTLS);
         $("#fSkipVerify").prop("checked", d.SkipTLSVerify);
@@ -637,7 +646,7 @@
     function saveEditor(){
         var ep = collectForm();
         if (ep.Mode === "path" && !ep.Trusted){
-            $("#saveError").text("Tick \"I trust this container\" or use subdomain mode.");
+            $("#saveError").text(T("editor/needTrust", "Tick \"I trust this container\" or use subdomain mode."));
             return;
         }
         var btn = $("#saveBtn").prop("disabled", true);
@@ -647,17 +656,17 @@
                 $("#saveError").text(res.error);
                 return;
             }
-            toast(state.editing ? "Saved" : res.Name + " published", true);
+            toast(state.editing ? T("editor/saved", "Saved") : T("editor/published", "{name} published", {name: res.Name}), true);
             closeEditor();
             loadManage();
         }).fail(function(){
-            $("#saveError").text("Unable to reach the server");
+            $("#saveError").text(T("editor/unreachable", "Unable to reach the server"));
         }).always(function(){ btn.prop("disabled", false); });
     }
 
     function deleteEndpoint(){
         var ep = state.editing;
-        if (!ep || !confirm("Remove " + ep.Name + "? Desktop shortcuts to it will stop working.")){
+        if (!ep || !confirm(T("editor/confirmRemove", "Remove {name}? Desktop shortcuts to it will stop working.", {name: ep.Name}))){
             return;
         }
         $.post(API + "admin/delete", {slug: ep.Slug}, function(res){
@@ -665,24 +674,27 @@
                 $("#saveError").text(res.error);
                 return;
             }
-            toast(ep.Name + " removed", true);
+            toast(T("editor/removed", "{name} removed", {name: ep.Name}), true);
             closeEditor();
             loadManage();
         });
     }
 
-    var VERDICT_TEXT = {
-        "path": "Works under a path as is",
-        "path-rewrite": "Works under a path with root path rewriting",
-        "subdomain": "Subdomain mode recommended"
-    };
+    function verdictText(verdict){
+        switch (verdict){
+            case "path": return T("probe/path", "Works under a path as is");
+            case "path-rewrite": return T("probe/pathRewrite", "Works under a path with root path rewriting");
+            case "subdomain": return T("probe/subdomain", "Subdomain mode recommended");
+        }
+        return T("probe/unknown", "Unknown");
+    }
 
     function runProbe(){
         var target = $("#fTarget").val().trim();
         if (target === ""){
             return;
         }
-        var box = $("#probeResult").removeClass("hidden").html('<div class="ca-note">Analyzing the app...</div>');
+        var box = $("#probeResult").removeClass("hidden").html('<div class="ca-note">' + T("probe/analyzing", "Analyzing the app...") + '</div>');
         var base = state.editing || state.prefill || {};
         $.get(API + "admin/probe", {
             target: target,
@@ -697,13 +709,13 @@
             }
             var cls = res.Reachable ? "info" : "warn";
             var head = res.Reachable
-                ? '<div class="ca-verdict">' + esc(VERDICT_TEXT[res.Verdict] || "Unknown") + '</div>'
-                : '<div class="ca-verdict">The app did not answer' + (res.Error ? ": " + esc(res.Error) : "") + '</div>';
+                ? '<div class="ca-verdict">' + esc(verdictText(res.Verdict)) + '</div>'
+                : '<div class="ca-verdict">' + T("probe/noAnswer", "The app did not answer") + (res.Error ? ": " + esc(res.Error) : "") + '</div>';
             if (res.KnownApp){
-                head += '<div>Recognised as ' + esc(res.KnownApp) + '</div>';
+                head += '<div>' + esc(T("probe/recognised", "Recognised as {name}", {name: res.KnownApp})) + '</div>';
             }
             var reasons = (res.Reasons || []).map(function(r){ return "<li>" + esc(r) + "</li>"; }).join("");
-            var apply = res.Verdict && !state.editing ? '<div style="margin-top:8px;"><button class="ca-btn sm" id="applyVerdict">Use this recommendation</button></div>' : "";
+            var apply = res.Verdict && !state.editing ? '<div style="margin-top:8px;"><button class="ca-btn sm" id="applyVerdict">' + T("probe/apply", "Use this recommendation") + '</button></div>' : "";
             box.html('<div class="ca-note ' + cls + '">' + head + (reasons ? "<ul>" + reasons + "</ul>" : "") + apply + '</div>');
             if (res.Title && $("#fName").val().trim() === ""){
                 $("#fName").val(res.Title).trigger("input");
@@ -713,7 +725,7 @@
             if (!state.editing && res.Verdict){
                 applyVerdict(res);
             }
-        }).fail(function(){ box.html('<div class="ca-note warn">Unable to analyze the app.</div>'); });
+        }).fail(function(){ box.html('<div class="ca-note warn">' + T("probe/failed", "Unable to analyze the app.") + '</div>'); });
     }
 
     function applyVerdict(res){
@@ -741,11 +753,11 @@
         loadManage(function(){
             var c = state.containers.filter(function(x){ return x.Name === name; })[0];
             if (!c){
-                toast("Container " + name + " not found", false);
+                toast(T("hash/notFound", "Container {name} not found", {name: name}), false);
                 return;
             }
             if (!c.Targets || c.Targets.length === 0){
-                toast(name + " does not publish a port on this host. Add a port mapping first, or enter the address by hand.", false);
+                toast(T("hash/noPort", "{name} does not publish a port on this host. Add a port mapping first, or enter the address by hand.", {name: name}), false);
                 openEditor(null, {Name: name, Slug: normalizeSlug(name), Container: name, Image: c.Image});
                 return;
             }
@@ -776,7 +788,15 @@
     window.addEventListener("hashchange", function(){ if (state.isAdmin){ handleHash(); } });
 
     applyTheme();
-    loadApps();
+    //Translate the page before the first render, or start in English without a locale file
+    if (typeof applocale !== "undefined" && applocale){
+        applocale.init("locale/containerapps.json", function(){
+            applocale.translate();
+            loadApps();
+        }, loadApps);
+    }else{
+        loadApps();
+    }
 })();
 </script>
 </body>

+ 678 - 0
src/web/ContainerApps/locale/containerapps.json

@@ -0,0 +1,678 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fwtitle": "容器應用程式",
+            "strings": {
+                "common/cancel": "取消",
+                "common/save": "儲存",
+                "app/title": "容器應用程式",
+                "common/loading": "載入中…",
+                "view/apps": "應用程式",
+                "view/manage": "管理",
+                "apps/none": "尚未發佈任何容器應用程式。",
+                "apps/emptyAdmin": "尚未發佈任何容器應用程式。請使用「發佈應用程式」,或在「管理」分頁發佈執行中的容器。",
+                "apps/emptyUser": "目前還沒有可供您使用的容器應用程式。",
+                "apps/pin": "加入桌面",
+                "apps/pinFailed": "無法加入桌面",
+                "apps/pinFailedErr": "無法加入桌面:{error}",
+                "apps/pinned": "已將 {name} 加入桌面",
+                "manage/published": "已發佈的應用程式",
+                "manage/app": "應用程式",
+                "manage/access": "存取方式",
+                "manage/traffic": "流量",
+                "manage/containers": "具有網頁連接埠的容器",
+                "manage/container": "容器",
+                "manage/ports": "已發佈的連接埠",
+                "manage/rewrite": "改寫",
+                "manage/public": "公開",
+                "manage/allUsers": "所有使用者",
+                "manage/nothing": "尚未發佈任何項目。",
+                "manage/requests": "{n} 個請求",
+                "manage/recent": "最近:{list}",
+                "manage/leaks": "已攔截 {n} 個根目錄請求",
+                "manage/considerRewrite": ",建議啟用改寫",
+                "manage/open": "開啟",
+                "manage/edit": "編輯",
+                "manage/noDocker": "Docker 無法使用,或沒有任何容器。",
+                "manage/noPorts": "沒有容器在此主機上發佈連接埠。",
+                "manage/containerPort": "容器連接埠 {port}",
+                "manage/publishedAs": "已發佈為 {list}",
+                "manage/publish": "發佈",
+                "manage/publishPort": "發佈 :{port}",
+                "editor/publish": "發佈應用程式",
+                "editor/editTitle": "編輯 {name}",
+                "editor/webServer": "網頁伺服器",
+                "editor/target": "目標位址",
+                "editor/analyze": "分析",
+                "editor/targetHint": "ArozOS 用來連線至網頁介面的 host:port,通常是容器發佈的連接埠。",
+                "editor/tls": "應用程式本身使用 HTTPS",
+                "editor/tlsHint": "僅在容器於此連接埠提供 https 時勾選。",
+                "editor/skipVerify": "接受自簽憑證",
+                "editor/app": "應用程式",
+                "editor/name": "名稱",
+                "editor/slug": "位址名稱",
+                "editor/icon": "圖示",
+                "editor/iconHint": "相對於 ArozOS 根目錄的網頁路徑,或圖片網址。",
+                "editor/served": "提供方式",
+                "editor/path": "路徑",
+                "editor/pathDesc": "使用此位址:{path}。無需額外設定。",
+                "editor/namePlaceholder": "<名稱>",
+                "editor/subdomain": "子網域",
+                "editor/subdomainDesc": "使用獨立的主機名稱,例如 <span class=\"ca-mono\">app.example.com</span>。與 ArozOS 隔離。",
+                "editor/pathWarn": "路徑模式下,應用程式與 ArozOS 共用同一位址,其網頁可以用目前登入的使用者身分操作 ArozOS,因此只應用於您信任的軟體。其他情況請選擇子網域模式。",
+                "editor/trusted": "我信任此容器",
+                "editor/trustedHint": "路徑模式必須勾選。",
+                "editor/rootRedirect": "攔截根目錄請求",
+                "editor/rootRedirectHint": "當應用程式向「/」請求檔案時,將請求導回應用程式,而不是 ArozOS 的網頁根目錄。",
+                "editor/rewrite": "改寫根路徑(注入指令碼)",
+                "editor/rewriteHint": "改寫網頁與樣式表中的連結,並注入一段小型指令碼來轉送應用程式自身的請求。大多數設計為在「/」執行的應用程式都需要此選項。",
+                "editor/hostname": "主機名稱",
+                "editor/hostnameHint": "請將此名稱指向 ArozOS 所在的伺服器(DNS 記錄,或 Cloudflare Tunnel 中的公開主機名稱)。Cloudflare 的免費憑證只涵蓋一層子網域,因此建議使用 <span class=\"ca-mono\">app.example.com</span> 或 <span class=\"ca-mono\">app-nas.example.com</span>,而非 <span class=\"ca-mono\">app.nas.example.com</span>。",
+                "editor/portal": "ArozOS 位址",
+                "editor/portalHint": "使用者在進入應用程式前登入的位置。",
+                "editor/ipWarning": "您正透過 IP 位址使用 ArozOS。子網域模式需要網域名稱。",
+                "editor/framing": "允許在桌面視窗中開啟",
+                "editor/framingHint": "移除應用程式「禁止嵌入框架」的標頭,讓它可以在桌面視窗中開啟。",
+                "editor/who": "誰可以開啟",
+                "editor/requireLogin": "需要登入 ArozOS",
+                "editor/requireLoginHint": "僅在應用程式需要公開時關閉,例如狀態頁面。",
+                "editor/groups": "權限群組",
+                "editor/groupsHint": "未選取任何群組時,所有使用者都能看到並將其釘選到桌面。",
+                "editor/groupsFailed": "無法載入群組",
+                "editor/opening": "從桌面開啟",
+                "editor/openIn": "開啟方式",
+                "editor/openFloat": "桌面視窗",
+                "editor/openTab": "新的瀏覽器分頁",
+                "editor/width": "寬度",
+                "editor/height": "高度",
+                "editor/remove": "移除",
+                "editor/opensAt": "開啟位置:{url}",
+                "editor/needTrust": "請勾選「我信任此容器」,或改用子網域模式。",
+                "editor/saved": "已儲存",
+                "editor/published": "已發佈 {name}",
+                "editor/unreachable": "無法連線至伺服器",
+                "editor/confirmRemove": "要移除 {name} 嗎?指向它的桌面捷徑將無法使用。",
+                "editor/removed": "已移除 {name}",
+                "probe/path": "可直接在路徑下運作",
+                "probe/pathRewrite": "啟用根路徑改寫後可在路徑下運作",
+                "probe/subdomain": "建議使用子網域模式",
+                "probe/unknown": "未知",
+                "probe/analyzing": "正在分析應用程式…",
+                "probe/noAnswer": "應用程式沒有回應",
+                "probe/recognised": "識別為 {name}",
+                "probe/apply": "採用此建議",
+                "probe/failed": "無法分析應用程式。",
+                "hash/notFound": "找不到容器 {name}",
+                "hash/noPort": "{name} 沒有在此主機上發佈連接埠。請先加入連接埠對應,或手動輸入位址。"
+            },
+            "titles": {
+                "Refresh": "重新整理",
+                "Close": "關閉"
+            },
+            "placeholder": {}
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fwtitle": "容器應用程式",
+            "strings": {
+                "common/cancel": "取消",
+                "common/save": "儲存",
+                "app/title": "容器應用程式",
+                "common/loading": "載入中…",
+                "view/apps": "應用程式",
+                "view/manage": "管理",
+                "apps/none": "尚未發佈任何容器應用程式。",
+                "apps/emptyAdmin": "尚未發佈任何容器應用程式。請使用「發佈應用程式」,或在「管理」分頁發佈執行中的容器。",
+                "apps/emptyUser": "目前還沒有可供您使用的容器應用程式。",
+                "apps/pin": "加入桌面",
+                "apps/pinFailed": "無法加入桌面",
+                "apps/pinFailedErr": "無法加入桌面:{error}",
+                "apps/pinned": "已將 {name} 加入桌面",
+                "manage/published": "已發佈的應用程式",
+                "manage/app": "應用程式",
+                "manage/access": "存取方式",
+                "manage/traffic": "流量",
+                "manage/containers": "具有網頁連接埠的容器",
+                "manage/container": "容器",
+                "manage/ports": "已發佈的連接埠",
+                "manage/rewrite": "改寫",
+                "manage/public": "公開",
+                "manage/allUsers": "所有使用者",
+                "manage/nothing": "尚未發佈任何項目。",
+                "manage/requests": "{n} 個請求",
+                "manage/recent": "最近:{list}",
+                "manage/leaks": "已攔截 {n} 個根目錄請求",
+                "manage/considerRewrite": ",建議啟用改寫",
+                "manage/open": "開啟",
+                "manage/edit": "編輯",
+                "manage/noDocker": "Docker 無法使用,或沒有任何容器。",
+                "manage/noPorts": "沒有容器在此主機上發佈連接埠。",
+                "manage/containerPort": "容器連接埠 {port}",
+                "manage/publishedAs": "已發佈為 {list}",
+                "manage/publish": "發佈",
+                "manage/publishPort": "發佈 :{port}",
+                "editor/publish": "發佈應用程式",
+                "editor/editTitle": "編輯 {name}",
+                "editor/webServer": "網頁伺服器",
+                "editor/target": "目標位址",
+                "editor/analyze": "分析",
+                "editor/targetHint": "ArozOS 用來連線至網頁介面的 host:port,通常是容器發佈的連接埠。",
+                "editor/tls": "應用程式本身使用 HTTPS",
+                "editor/tlsHint": "僅在容器於此連接埠提供 https 時勾選。",
+                "editor/skipVerify": "接受自簽憑證",
+                "editor/app": "應用程式",
+                "editor/name": "名稱",
+                "editor/slug": "位址名稱",
+                "editor/icon": "圖示",
+                "editor/iconHint": "相對於 ArozOS 根目錄的網頁路徑,或圖片網址。",
+                "editor/served": "提供方式",
+                "editor/path": "路徑",
+                "editor/pathDesc": "使用此位址:{path}。無需額外設定。",
+                "editor/namePlaceholder": "<名稱>",
+                "editor/subdomain": "子網域",
+                "editor/subdomainDesc": "使用獨立的主機名稱,例如 <span class=\"ca-mono\">app.example.com</span>。與 ArozOS 隔離。",
+                "editor/pathWarn": "路徑模式下,應用程式與 ArozOS 共用同一位址,其網頁可以用目前登入的使用者身分操作 ArozOS,因此只應用於您信任的軟體。其他情況請選擇子網域模式。",
+                "editor/trusted": "我信任此容器",
+                "editor/trustedHint": "路徑模式必須勾選。",
+                "editor/rootRedirect": "攔截根目錄請求",
+                "editor/rootRedirectHint": "當應用程式向「/」請求檔案時,將請求導回應用程式,而不是 ArozOS 的網頁根目錄。",
+                "editor/rewrite": "改寫根路徑(注入指令碼)",
+                "editor/rewriteHint": "改寫網頁與樣式表中的連結,並注入一段小型指令碼來轉送應用程式自身的請求。大多數設計為在「/」執行的應用程式都需要此選項。",
+                "editor/hostname": "主機名稱",
+                "editor/hostnameHint": "請將此名稱指向 ArozOS 所在的伺服器(DNS 記錄,或 Cloudflare Tunnel 中的公開主機名稱)。Cloudflare 的免費憑證只涵蓋一層子網域,因此建議使用 <span class=\"ca-mono\">app.example.com</span> 或 <span class=\"ca-mono\">app-nas.example.com</span>,而非 <span class=\"ca-mono\">app.nas.example.com</span>。",
+                "editor/portal": "ArozOS 位址",
+                "editor/portalHint": "使用者在進入應用程式前登入的位置。",
+                "editor/ipWarning": "您正透過 IP 位址使用 ArozOS。子網域模式需要網域名稱。",
+                "editor/framing": "允許在桌面視窗中開啟",
+                "editor/framingHint": "移除應用程式「禁止嵌入框架」的標頭,讓它可以在桌面視窗中開啟。",
+                "editor/who": "誰可以開啟",
+                "editor/requireLogin": "需要登入 ArozOS",
+                "editor/requireLoginHint": "僅在應用程式需要公開時關閉,例如狀態頁面。",
+                "editor/groups": "權限群組",
+                "editor/groupsHint": "未選取任何群組時,所有使用者都能看到並將其釘選到桌面。",
+                "editor/groupsFailed": "無法載入群組",
+                "editor/opening": "從桌面開啟",
+                "editor/openIn": "開啟方式",
+                "editor/openFloat": "桌面視窗",
+                "editor/openTab": "新的瀏覽器分頁",
+                "editor/width": "寬度",
+                "editor/height": "高度",
+                "editor/remove": "移除",
+                "editor/opensAt": "開啟位置:{url}",
+                "editor/needTrust": "請勾選「我信任此容器」,或改用子網域模式。",
+                "editor/saved": "已儲存",
+                "editor/published": "已發佈 {name}",
+                "editor/unreachable": "無法連線至伺服器",
+                "editor/confirmRemove": "要移除 {name} 嗎?指向它的桌面捷徑將無法使用。",
+                "editor/removed": "已移除 {name}",
+                "probe/path": "可直接在路徑下運作",
+                "probe/pathRewrite": "啟用根路徑改寫後可在路徑下運作",
+                "probe/subdomain": "建議使用子網域模式",
+                "probe/unknown": "未知",
+                "probe/analyzing": "正在分析應用程式…",
+                "probe/noAnswer": "應用程式沒有回應",
+                "probe/recognised": "識別為 {name}",
+                "probe/apply": "採用此建議",
+                "probe/failed": "無法分析應用程式。",
+                "hash/notFound": "找不到容器 {name}",
+                "hash/noPort": "{name} 沒有在此主機上發佈連接埠。請先加入連接埠對應,或手動輸入位址。"
+            },
+            "titles": {
+                "Refresh": "重新整理",
+                "Close": "關閉"
+            },
+            "placeholder": {}
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fwtitle": "容器应用",
+            "strings": {
+                "common/cancel": "取消",
+                "common/save": "保存",
+                "app/title": "容器应用",
+                "common/loading": "加载中…",
+                "view/apps": "应用",
+                "view/manage": "管理",
+                "apps/none": "尚未发布任何容器应用。",
+                "apps/emptyAdmin": "尚未发布任何容器应用。请使用“发布应用”,或在“管理”选项卡中发布正在运行的容器。",
+                "apps/emptyUser": "目前还没有可供您使用的容器应用。",
+                "apps/pin": "添加到桌面",
+                "apps/pinFailed": "无法添加到桌面",
+                "apps/pinFailedErr": "无法添加到桌面:{error}",
+                "apps/pinned": "已将 {name} 添加到桌面",
+                "manage/published": "已发布的应用",
+                "manage/app": "应用",
+                "manage/access": "访问方式",
+                "manage/traffic": "流量",
+                "manage/containers": "带有网页端口的容器",
+                "manage/container": "容器",
+                "manage/ports": "已发布的端口",
+                "manage/rewrite": "重写",
+                "manage/public": "公开",
+                "manage/allUsers": "所有用户",
+                "manage/nothing": "尚未发布任何内容。",
+                "manage/requests": "{n} 个请求",
+                "manage/recent": "最近:{list}",
+                "manage/leaks": "已拦截 {n} 个根目录请求",
+                "manage/considerRewrite": ",建议启用重写",
+                "manage/open": "打开",
+                "manage/edit": "编辑",
+                "manage/noDocker": "Docker 不可用,或没有任何容器。",
+                "manage/noPorts": "没有容器在此主机上发布端口。",
+                "manage/containerPort": "容器端口 {port}",
+                "manage/publishedAs": "已发布为 {list}",
+                "manage/publish": "发布",
+                "manage/publishPort": "发布 :{port}",
+                "editor/publish": "发布应用",
+                "editor/editTitle": "编辑 {name}",
+                "editor/webServer": "网页服务器",
+                "editor/target": "目标地址",
+                "editor/analyze": "分析",
+                "editor/targetHint": "ArozOS 用来访问网页界面的 host:port,通常是容器发布的端口。",
+                "editor/tls": "应用本身使用 HTTPS",
+                "editor/tlsHint": "仅在容器于此端口提供 https 时勾选。",
+                "editor/skipVerify": "接受自签名证书",
+                "editor/app": "应用",
+                "editor/name": "名称",
+                "editor/slug": "地址名称",
+                "editor/icon": "图标",
+                "editor/iconHint": "相对于 ArozOS 根目录的网页路径,或图片 URL。",
+                "editor/served": "访问方式",
+                "editor/path": "路径",
+                "editor/pathDesc": "使用此地址:{path}。无需额外设置。",
+                "editor/namePlaceholder": "<名称>",
+                "editor/subdomain": "子域名",
+                "editor/subdomainDesc": "使用独立的主机名,例如 <span class=\"ca-mono\">app.example.com</span>。与 ArozOS 隔离。",
+                "editor/pathWarn": "路径模式下,应用与 ArozOS 共用同一地址,其网页可以以当前登录用户的身份操作 ArozOS,因此只应用于您信任的软件。其他情况请选择子域名模式。",
+                "editor/trusted": "我信任此容器",
+                "editor/trustedHint": "路径模式必须勾选。",
+                "editor/rootRedirect": "拦截根目录请求",
+                "editor/rootRedirectHint": "当应用向“/”请求文件时,将请求重定向回应用,而不是 ArozOS 的网页根目录。",
+                "editor/rewrite": "重写根路径(注入脚本)",
+                "editor/rewriteHint": "重写网页和样式表中的链接,并注入一段小脚本来转发应用自身的请求。大多数设计为在“/”运行的应用都需要此选项。",
+                "editor/hostname": "主机名",
+                "editor/hostnameHint": "请将此名称指向 ArozOS 所在的服务器(DNS 记录,或 Cloudflare Tunnel 中的公共主机名)。Cloudflare 的免费证书只覆盖一级子域名,因此建议使用 <span class=\"ca-mono\">app.example.com</span> 或 <span class=\"ca-mono\">app-nas.example.com</span>,而不是 <span class=\"ca-mono\">app.nas.example.com</span>。",
+                "editor/portal": "ArozOS 地址",
+                "editor/portalHint": "用户在进入应用前登录的位置。",
+                "editor/ipWarning": "您正在通过 IP 地址访问 ArozOS。子域名模式需要域名。",
+                "editor/framing": "允许在桌面窗口中打开",
+                "editor/framingHint": "移除应用“禁止嵌入框架”的标头,使其可以在桌面窗口中打开。",
+                "editor/who": "谁可以打开",
+                "editor/requireLogin": "需要登录 ArozOS",
+                "editor/requireLoginHint": "仅在应用需要公开时关闭,例如状态页面。",
+                "editor/groups": "权限组",
+                "editor/groupsHint": "未选择任何组时,所有用户都能看到并将其固定到桌面。",
+                "editor/groupsFailed": "无法加载用户组",
+                "editor/opening": "从桌面打开",
+                "editor/openIn": "打开方式",
+                "editor/openFloat": "桌面窗口",
+                "editor/openTab": "新的浏览器标签页",
+                "editor/width": "宽度",
+                "editor/height": "高度",
+                "editor/remove": "移除",
+                "editor/opensAt": "打开地址:{url}",
+                "editor/needTrust": "请勾选“我信任此容器”,或改用子域名模式。",
+                "editor/saved": "已保存",
+                "editor/published": "已发布 {name}",
+                "editor/unreachable": "无法连接到服务器",
+                "editor/confirmRemove": "要移除 {name} 吗?指向它的桌面快捷方式将无法使用。",
+                "editor/removed": "已移除 {name}",
+                "probe/path": "可直接在路径下运行",
+                "probe/pathRewrite": "启用根路径重写后可在路径下运行",
+                "probe/subdomain": "建议使用子域名模式",
+                "probe/unknown": "未知",
+                "probe/analyzing": "正在分析应用…",
+                "probe/noAnswer": "应用没有响应",
+                "probe/recognised": "识别为 {name}",
+                "probe/apply": "采用此建议",
+                "probe/failed": "无法分析应用。",
+                "hash/notFound": "未找到容器 {name}",
+                "hash/noPort": "{name} 没有在此主机上发布端口。请先添加端口映射,或手动输入地址。"
+            },
+            "titles": {
+                "Refresh": "刷新",
+                "Close": "关闭"
+            },
+            "placeholder": {}
+        },
+        "en-us": {
+            "name": "English (US)",
+            "fwtitle": "Container Apps",
+            "strings": {
+                "common/cancel": "Cancel",
+                "common/save": "Save",
+                "app/title": "Container Apps",
+                "common/loading": "Loading...",
+                "view/apps": "Apps",
+                "view/manage": "Manage",
+                "apps/none": "No container apps have been published yet.",
+                "apps/emptyAdmin": "No container apps have been published yet. Use Publish app, or the Manage tab to publish a running container.",
+                "apps/emptyUser": "No container apps have been published for you yet.",
+                "apps/pin": "Add to desktop",
+                "apps/pinFailed": "Unable to add to desktop",
+                "apps/pinFailedErr": "Unable to add to desktop: {error}",
+                "apps/pinned": "{name} added to your desktop",
+                "manage/published": "Published apps",
+                "manage/app": "App",
+                "manage/access": "Access",
+                "manage/traffic": "Traffic",
+                "manage/containers": "Containers with web ports",
+                "manage/container": "Container",
+                "manage/ports": "Published ports",
+                "manage/rewrite": "rewrite",
+                "manage/public": "public",
+                "manage/allUsers": "all users",
+                "manage/nothing": "Nothing published yet.",
+                "manage/requests": "{n} requests",
+                "manage/recent": "Recent: {list}",
+                "manage/leaks": "{n} root requests caught",
+                "manage/considerRewrite": ", consider rewriting",
+                "manage/open": "Open",
+                "manage/edit": "Edit",
+                "manage/noDocker": "Docker is not available, or there are no containers.",
+                "manage/noPorts": "No container publishes a port on this host.",
+                "manage/containerPort": "container port {port}",
+                "manage/publishedAs": "Published as {list}",
+                "manage/publish": "Publish",
+                "manage/publishPort": "Publish :{port}",
+                "editor/publish": "Publish app",
+                "editor/editTitle": "Edit {name}",
+                "editor/webServer": "Web server",
+                "editor/target": "Target address",
+                "editor/analyze": "Analyze",
+                "editor/targetHint": "The host:port ArozOS reaches the web interface on, usually a port published by the container.",
+                "editor/tls": "The app itself uses HTTPS",
+                "editor/tlsHint": "Only when the container serves https on this port.",
+                "editor/skipVerify": "Accept a self-signed certificate",
+                "editor/app": "App",
+                "editor/name": "Name",
+                "editor/slug": "Address name",
+                "editor/icon": "Icon",
+                "editor/iconHint": "A web path relative to the ArozOS root, or an image URL.",
+                "editor/served": "How it is served",
+                "editor/path": "Path",
+                "editor/pathDesc": "On this address: {path}. Nothing to set up.",
+                "editor/namePlaceholder": "<name>",
+                "editor/subdomain": "Subdomain",
+                "editor/subdomainDesc": "On its own host name, e.g. <span class=\"ca-mono\">app.example.com</span>. Isolated from ArozOS.",
+                "editor/pathWarn": "In path mode the app shares the ArozOS address. Its web pages can act as the signed in user on ArozOS, so only use it for software you trust. Choose subdomain mode for anything else.",
+                "editor/trusted": "I trust this container",
+                "editor/trustedHint": "Required for path mode.",
+                "editor/rootRedirect": "Catch root requests",
+                "editor/rootRedirectHint": "When the app asks for a file from \"/\", send it back to the app instead of the ArozOS web root.",
+                "editor/rewrite": "Rewrite root paths (inject script)",
+                "editor/rewriteHint": "Rewrites links in pages and style sheets and injects a small script that routes the app's own requests. Needed by most apps that were built to run at \"/\".",
+                "editor/hostname": "Host name",
+                "editor/hostnameHint": "Point this name at the same server as ArozOS (a DNS record, or a public hostname in Cloudflare Tunnel). Cloudflare's free certificate only covers one level, so prefer <span class=\"ca-mono\">app.example.com</span> or <span class=\"ca-mono\">app-nas.example.com</span> over <span class=\"ca-mono\">app.nas.example.com</span>.",
+                "editor/portal": "ArozOS address",
+                "editor/portalHint": "Where users sign in before being sent to the app.",
+                "editor/ipWarning": "You are using ArozOS through an IP address. Subdomain mode needs a domain name.",
+                "editor/framing": "Allow in desktop window",
+                "editor/framingHint": "Remove the app's \"do not frame me\" headers so it can open in a desktop window.",
+                "editor/who": "Who can open it",
+                "editor/requireLogin": "Require ArozOS sign in",
+                "editor/requireLoginHint": "Turn off only for apps meant to be public, for example a status page.",
+                "editor/groups": "Permission groups",
+                "editor/groupsHint": "None selected means every user can see it and pin it to their desktop.",
+                "editor/groupsFailed": "Unable to load groups",
+                "editor/opening": "Opening from the desktop",
+                "editor/openIn": "Open in",
+                "editor/openFloat": "Desktop window",
+                "editor/openTab": "New browser tab",
+                "editor/width": "Width",
+                "editor/height": "Height",
+                "editor/remove": "Remove",
+                "editor/opensAt": "Opens at {url}",
+                "editor/needTrust": "Tick \"I trust this container\" or use subdomain mode.",
+                "editor/saved": "Saved",
+                "editor/published": "{name} published",
+                "editor/unreachable": "Unable to reach the server",
+                "editor/confirmRemove": "Remove {name}? Desktop shortcuts to it will stop working.",
+                "editor/removed": "{name} removed",
+                "probe/path": "Works under a path as is",
+                "probe/pathRewrite": "Works under a path with root path rewriting",
+                "probe/subdomain": "Subdomain mode recommended",
+                "probe/unknown": "Unknown",
+                "probe/analyzing": "Analyzing the app...",
+                "probe/noAnswer": "The app did not answer",
+                "probe/recognised": "Recognised as {name}",
+                "probe/apply": "Use this recommendation",
+                "probe/failed": "Unable to analyze the app.",
+                "hash/notFound": "Container {name} not found",
+                "hash/noPort": "{name} does not publish a port on this host. Add a port mapping first, or enter the address by hand."
+            },
+            "titles": {
+                "Refresh": "Refresh",
+                "Close": "Close"
+            },
+            "placeholder": {}
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fwtitle": "コンテナアプリ",
+            "strings": {
+                "common/cancel": "キャンセル",
+                "common/save": "保存",
+                "app/title": "コンテナアプリ",
+                "common/loading": "読み込み中…",
+                "view/apps": "アプリ",
+                "view/manage": "管理",
+                "apps/none": "公開されたコンテナアプリはまだありません。",
+                "apps/emptyAdmin": "公開されたコンテナアプリはまだありません。「アプリを公開」または「管理」タブから実行中のコンテナを公開してください。",
+                "apps/emptyUser": "利用できるコンテナアプリはまだありません。",
+                "apps/pin": "デスクトップに追加",
+                "apps/pinFailed": "デスクトップに追加できません",
+                "apps/pinFailedErr": "デスクトップに追加できません: {error}",
+                "apps/pinned": "{name} をデスクトップに追加しました",
+                "manage/published": "公開済みのアプリ",
+                "manage/app": "アプリ",
+                "manage/access": "アクセス",
+                "manage/traffic": "トラフィック",
+                "manage/containers": "Web ポートを持つコンテナ",
+                "manage/container": "コンテナ",
+                "manage/ports": "公開ポート",
+                "manage/rewrite": "書き換え",
+                "manage/public": "公開",
+                "manage/allUsers": "全ユーザー",
+                "manage/nothing": "まだ何も公開されていません。",
+                "manage/requests": "{n} 件のリクエスト",
+                "manage/recent": "最近: {list}",
+                "manage/leaks": "ルートへのリクエストを {n} 件捕捉",
+                "manage/considerRewrite": "、書き換えを検討してください",
+                "manage/open": "開く",
+                "manage/edit": "編集",
+                "manage/noDocker": "Docker が利用できないか、コンテナがありません。",
+                "manage/noPorts": "このホストでポートを公開しているコンテナはありません。",
+                "manage/containerPort": "コンテナポート {port}",
+                "manage/publishedAs": "{list} として公開済み",
+                "manage/publish": "公開",
+                "manage/publishPort": "公開 :{port}",
+                "editor/publish": "アプリを公開",
+                "editor/editTitle": "{name} を編集",
+                "editor/webServer": "Web サーバー",
+                "editor/target": "ターゲットアドレス",
+                "editor/analyze": "分析",
+                "editor/targetHint": "ArozOS が Web インターフェースに接続する host:port。通常はコンテナが公開しているポートです。",
+                "editor/tls": "アプリ自体が HTTPS を使用",
+                "editor/tlsHint": "コンテナがこのポートで https を提供している場合のみ。",
+                "editor/skipVerify": "自己署名証明書を許可",
+                "editor/app": "アプリ",
+                "editor/name": "名前",
+                "editor/slug": "アドレス名",
+                "editor/icon": "アイコン",
+                "editor/iconHint": "ArozOS ルートからの相対 Web パス、または画像の URL。",
+                "editor/served": "公開方法",
+                "editor/path": "パス",
+                "editor/pathDesc": "このアドレスで公開: {path}。設定は不要です。",
+                "editor/namePlaceholder": "<名前>",
+                "editor/subdomain": "サブドメイン",
+                "editor/subdomainDesc": "専用のホスト名を使用(例: <span class=\"ca-mono\">app.example.com</span>)。ArozOS から分離されます。",
+                "editor/pathWarn": "パスモードではアプリが ArozOS と同じアドレスを共有し、そのページはサインイン中のユーザーとして ArozOS を操作できます。信頼できるソフトウェアにのみ使用し、それ以外はサブドメインモードを選んでください。",
+                "editor/trusted": "このコンテナを信頼する",
+                "editor/trustedHint": "パスモードに必須です。",
+                "editor/rootRedirect": "ルートへのリクエストを捕捉",
+                "editor/rootRedirectHint": "アプリが「/」のファイルを要求したとき、ArozOS の Web ルートではなくアプリに戻します。",
+                "editor/rewrite": "ルートパスを書き換える(スクリプトを挿入)",
+                "editor/rewriteHint": "ページとスタイルシート内のリンクを書き換え、アプリ自身のリクエストを中継する小さなスクリプトを挿入します。「/」で動作するように作られた多くのアプリで必要です。",
+                "editor/hostname": "ホスト名",
+                "editor/hostnameHint": "この名前を ArozOS と同じサーバーに向けてください(DNS レコード、または Cloudflare Tunnel の公開ホスト名)。Cloudflare の無料証明書は 1 階層のサブドメインのみ対応するため、<span class=\"ca-mono\">app.nas.example.com</span> より <span class=\"ca-mono\">app.example.com</span> や <span class=\"ca-mono\">app-nas.example.com</span> を推奨します。",
+                "editor/portal": "ArozOS のアドレス",
+                "editor/portalHint": "アプリに移動する前にユーザーがサインインする場所です。",
+                "editor/ipWarning": "IP アドレスで ArozOS にアクセスしています。サブドメインモードにはドメイン名が必要です。",
+                "editor/framing": "デスクトップウィンドウでの表示を許可",
+                "editor/framingHint": "アプリの「フレーム内表示を禁止」ヘッダーを取り除き、デスクトップウィンドウで開けるようにします。",
+                "editor/who": "開けるユーザー",
+                "editor/requireLogin": "ArozOS へのサインインを必須にする",
+                "editor/requireLoginHint": "公開を前提としたアプリ(ステータスページなど)の場合のみオフにしてください。",
+                "editor/groups": "権限グループ",
+                "editor/groupsHint": "何も選択しない場合、すべてのユーザーが表示してデスクトップにピン留めできます。",
+                "editor/groupsFailed": "グループを読み込めません",
+                "editor/opening": "デスクトップから開く",
+                "editor/openIn": "開く場所",
+                "editor/openFloat": "デスクトップウィンドウ",
+                "editor/openTab": "新しいブラウザタブ",
+                "editor/width": "幅",
+                "editor/height": "高さ",
+                "editor/remove": "削除",
+                "editor/opensAt": "開く場所: {url}",
+                "editor/needTrust": "「このコンテナを信頼する」にチェックを入れるか、サブドメインモードを使用してください。",
+                "editor/saved": "保存しました",
+                "editor/published": "{name} を公開しました",
+                "editor/unreachable": "サーバーに接続できません",
+                "editor/confirmRemove": "{name} を削除しますか?このアプリへのデスクトップショートカットは使えなくなります。",
+                "editor/removed": "{name} を削除しました",
+                "probe/path": "そのままパスで動作します",
+                "probe/pathRewrite": "ルートパスの書き換えでパスで動作します",
+                "probe/subdomain": "サブドメインモードを推奨",
+                "probe/unknown": "不明",
+                "probe/analyzing": "アプリを分析中…",
+                "probe/noAnswer": "アプリから応答がありません",
+                "probe/recognised": "{name} として認識されました",
+                "probe/apply": "この推奨設定を使用",
+                "probe/failed": "アプリを分析できません。",
+                "hash/notFound": "コンテナ {name} が見つかりません",
+                "hash/noPort": "{name} はこのホストでポートを公開していません。先にポートマッピングを追加するか、アドレスを手動で入力してください。"
+            },
+            "titles": {
+                "Refresh": "更新",
+                "Close": "閉じる"
+            },
+            "placeholder": {}
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fwtitle": "컨테이너 앱",
+            "strings": {
+                "common/cancel": "취소",
+                "common/save": "저장",
+                "app/title": "컨테이너 앱",
+                "common/loading": "불러오는 중…",
+                "view/apps": "앱",
+                "view/manage": "관리",
+                "apps/none": "아직 게시된 컨테이너 앱이 없습니다.",
+                "apps/emptyAdmin": "아직 게시된 컨테이너 앱이 없습니다. \"앱 게시\" 또는 \"관리\" 탭에서 실행 중인 컨테이너를 게시하세요.",
+                "apps/emptyUser": "아직 사용할 수 있는 컨테이너 앱이 없습니다.",
+                "apps/pin": "바탕 화면에 추가",
+                "apps/pinFailed": "바탕 화면에 추가할 수 없습니다",
+                "apps/pinFailedErr": "바탕 화면에 추가할 수 없습니다: {error}",
+                "apps/pinned": "{name}을(를) 바탕 화면에 추가했습니다",
+                "manage/published": "게시된 앱",
+                "manage/app": "앱",
+                "manage/access": "접근",
+                "manage/traffic": "트래픽",
+                "manage/containers": "웹 포트가 있는 컨테이너",
+                "manage/container": "컨테이너",
+                "manage/ports": "게시된 포트",
+                "manage/rewrite": "재작성",
+                "manage/public": "공개",
+                "manage/allUsers": "모든 사용자",
+                "manage/nothing": "아직 게시된 항목이 없습니다.",
+                "manage/requests": "요청 {n}개",
+                "manage/recent": "최근: {list}",
+                "manage/leaks": "루트 요청 {n}개 가로챔",
+                "manage/considerRewrite": ", 재작성을 고려하세요",
+                "manage/open": "열기",
+                "manage/edit": "편집",
+                "manage/noDocker": "Docker를 사용할 수 없거나 컨테이너가 없습니다.",
+                "manage/noPorts": "이 호스트에서 포트를 게시한 컨테이너가 없습니다.",
+                "manage/containerPort": "컨테이너 포트 {port}",
+                "manage/publishedAs": "{list}(으)로 게시됨",
+                "manage/publish": "게시",
+                "manage/publishPort": "게시 :{port}",
+                "editor/publish": "앱 게시",
+                "editor/editTitle": "{name} 편집",
+                "editor/webServer": "웹 서버",
+                "editor/target": "대상 주소",
+                "editor/analyze": "분석",
+                "editor/targetHint": "ArozOS가 웹 인터페이스에 접속하는 host:port로, 보통 컨테이너가 게시한 포트입니다.",
+                "editor/tls": "앱 자체가 HTTPS 사용",
+                "editor/tlsHint": "컨테이너가 이 포트에서 https를 제공하는 경우에만 선택하세요.",
+                "editor/skipVerify": "자체 서명 인증서 허용",
+                "editor/app": "앱",
+                "editor/name": "이름",
+                "editor/slug": "주소 이름",
+                "editor/icon": "아이콘",
+                "editor/iconHint": "ArozOS 루트 기준 웹 경로 또는 이미지 URL.",
+                "editor/served": "제공 방식",
+                "editor/path": "경로",
+                "editor/pathDesc": "이 주소에서: {path}. 별도 설정이 필요 없습니다.",
+                "editor/namePlaceholder": "<이름>",
+                "editor/subdomain": "하위 도메인",
+                "editor/subdomainDesc": "별도의 호스트 이름 사용 (예: <span class=\"ca-mono\">app.example.com</span>). ArozOS와 격리됩니다.",
+                "editor/pathWarn": "경로 모드에서는 앱이 ArozOS 주소를 공유하므로 앱의 웹 페이지가 로그인한 사용자로서 ArozOS를 조작할 수 있습니다. 신뢰하는 소프트웨어에만 사용하고, 그 외에는 하위 도메인 모드를 선택하세요.",
+                "editor/trusted": "이 컨테이너를 신뢰합니다",
+                "editor/trustedHint": "경로 모드에 필요합니다.",
+                "editor/rootRedirect": "루트 요청 가로채기",
+                "editor/rootRedirectHint": "앱이 \"/\"에서 파일을 요청하면 ArozOS 웹 루트 대신 앱으로 돌려보냅니다.",
+                "editor/rewrite": "루트 경로 재작성 (스크립트 삽입)",
+                "editor/rewriteHint": "페이지와 스타일시트의 링크를 재작성하고 앱 자체 요청을 전달하는 작은 스크립트를 삽입합니다. \"/\"에서 실행되도록 만들어진 대부분의 앱에 필요합니다.",
+                "editor/hostname": "호스트 이름",
+                "editor/hostnameHint": "이 이름이 ArozOS와 같은 서버를 가리키도록 설정하세요 (DNS 레코드 또는 Cloudflare Tunnel의 공개 호스트 이름). Cloudflare 무료 인증서는 한 단계 하위 도메인만 지원하므로 <span class=\"ca-mono\">app.nas.example.com</span>보다 <span class=\"ca-mono\">app.example.com</span> 또는 <span class=\"ca-mono\">app-nas.example.com</span>를 사용하세요.",
+                "editor/portal": "ArozOS 주소",
+                "editor/portalHint": "사용자가 앱으로 이동하기 전에 로그인하는 곳입니다.",
+                "editor/ipWarning": "IP 주소로 ArozOS를 사용하고 있습니다. 하위 도메인 모드에는 도메인 이름이 필요합니다.",
+                "editor/framing": "바탕 화면 창에서 열기 허용",
+                "editor/framingHint": "앱의 \"프레임 내 표시 금지\" 헤더를 제거하여 바탕 화면 창에서 열 수 있게 합니다.",
+                "editor/who": "열 수 있는 사용자",
+                "editor/requireLogin": "ArozOS 로그인 필요",
+                "editor/requireLoginHint": "상태 페이지처럼 공개용 앱인 경우에만 끄세요.",
+                "editor/groups": "권한 그룹",
+                "editor/groupsHint": "아무것도 선택하지 않으면 모든 사용자가 보고 바탕 화면에 고정할 수 있습니다.",
+                "editor/groupsFailed": "그룹을 불러올 수 없습니다",
+                "editor/opening": "바탕 화면에서 열기",
+                "editor/openIn": "열기 위치",
+                "editor/openFloat": "바탕 화면 창",
+                "editor/openTab": "새 브라우저 탭",
+                "editor/width": "너비",
+                "editor/height": "높이",
+                "editor/remove": "제거",
+                "editor/opensAt": "열리는 주소: {url}",
+                "editor/needTrust": "\"이 컨테이너를 신뢰합니다\"를 선택하거나 하위 도메인 모드를 사용하세요.",
+                "editor/saved": "저장되었습니다",
+                "editor/published": "{name}을(를) 게시했습니다",
+                "editor/unreachable": "서버에 연결할 수 없습니다",
+                "editor/confirmRemove": "{name}을(를) 제거하시겠습니까? 이 앱의 바탕 화면 바로 가기가 작동하지 않게 됩니다.",
+                "editor/removed": "{name}을(를) 제거했습니다",
+                "probe/path": "경로에서 그대로 작동합니다",
+                "probe/pathRewrite": "루트 경로 재작성 시 경로에서 작동합니다",
+                "probe/subdomain": "하위 도메인 모드 권장",
+                "probe/unknown": "알 수 없음",
+                "probe/analyzing": "앱 분석 중…",
+                "probe/noAnswer": "앱이 응답하지 않았습니다",
+                "probe/recognised": "{name}(으)로 인식됨",
+                "probe/apply": "이 권장 사항 사용",
+                "probe/failed": "앱을 분석할 수 없습니다.",
+                "hash/notFound": "컨테이너 {name}을(를) 찾을 수 없습니다",
+                "hash/noPort": "{name}은(는) 이 호스트에서 포트를 게시하지 않습니다. 먼저 포트 매핑을 추가하거나 주소를 직접 입력하세요."
+            },
+            "titles": {
+                "Refresh": "새로 고침",
+                "Close": "닫기"
+            },
+            "placeholder": {}
+        }
+    }
+}

+ 10 - 8
src/web/DockerManager/console.html

@@ -7,6 +7,7 @@
     <link rel="stylesheet" href="script/xterm/xterm.css">
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <script src="../script/applocale.js"></script>
     <script src="script/dockermanager.js"></script>
     <script src="script/xterm/xterm.js"></script>
     <script src="script/xterm/xterm-addon-fit.js"></script>
@@ -23,21 +24,22 @@
 
 <body>
     <div id="bar">
-        <span class="warn">Live container shell</span>
+        <span class="warn" locale="console/live">Live container shell</span>
         <span id="cname" style="font-family:ui-monospace,Menlo,Consolas,monospace;"></span>
-        <span style="margin-left:auto;">Shell:</span>
+        <span style="margin-left:auto;" locale="console/shell">Shell:</span>
         <select id="shell"><option value="sh">sh</option><option value="bash">bash</option></select>
-        <button id="reconnect">Reconnect</button>
+        <button id="reconnect" locale="console/reconnect">Reconnect</button>
     </div>
     <div id="term"></div>
 
     <script>
-    (function () {
+    DM.initLocale("locale/console.json", function () {
+        var T = DM.t;
         function qp(name) { var m = new RegExp("[?&]" + name + "=([^&]*)").exec(window.location.search); return m ? decodeURIComponent(m[1]) : ""; }
         var id = qp("id"), name = qp("name") || id;
         document.getElementById("cname").textContent = name;
         if (qp("shell")) document.getElementById("shell").value = qp("shell");
-        if (typeof ao_module_setWindowTitle === "function") { try { ao_module_setWindowTitle("Console: " + name); } catch (e) {} }
+        if (typeof ao_module_setWindowTitle === "function") { try { ao_module_setWindowTitle(T("console/title", "Console: {name}", { name: name })); } catch (e) {} }
 
         var socket = null, term = null, fit = null;
         function sendResize() { if (term && socket && socket.readyState === 1) socket.send(JSON.stringify({ type: "resize", rows: term.rows, cols: term.cols })); }
@@ -52,15 +54,15 @@
             socket.binaryType = "arraybuffer";
             socket.onopen = function () { sendResize(); };
             socket.onmessage = function (ev) { if (typeof ev.data === "string") term.write(ev.data); else term.write(new Uint8Array(ev.data)); };
-            socket.onclose = function () { if (term) term.write("\r\n[session closed]\r\n"); };
+            socket.onclose = function () { if (term) term.write("\r\n[" + T("console/closed", "session closed") + "]\r\n"); };
             term.onData(function (d) { if (socket && socket.readyState === 1) socket.send(new TextEncoder().encode(d)); });
             term.onResize(sendResize);
         }
         window.addEventListener("resize", function () { if (fit && term) { try { fit.fit(); } catch (e) {} } });
         window.addEventListener("beforeunload", function () { if (socket) { try { socket.close(); } catch (e) {} } });
         document.getElementById("reconnect").addEventListener("click", connect);
-        if (id) connect(); else document.getElementById("term").textContent = "Missing container id.";
-    })();
+        if (id) connect(); else document.getElementById("term").textContent = T("console/missingId", "Missing container id.");
+    });
     </script>
 </body>
 

+ 194 - 189
src/web/DockerManager/index.html

@@ -9,6 +9,7 @@
     <link rel="stylesheet" href="script/xterm/xterm.css">
     <script src="../script/jquery.min.js"></script>
     <script src="../script/ao_module.js"></script>
+    <script src="../script/applocale.js"></script>
     <script src="script/dockermanager.js"></script>
     <script src="script/ace/ace.js"></script>
     <script src="script/xterm/xterm.js"></script>
@@ -236,110 +237,110 @@
 
     <div class="dm-app">
         <nav class="dm-sidebar">
-            <div class="dm-brand"><img src="img/icon.svg" alt=""> <span>Docker Manager</span></div>
-            <div class="dm-nav-item active" data-section="containers"><span class="ic ic-container"></span><span>Containers</span></div>
-            <div class="dm-nav-item" data-section="images"><span class="ic ic-images"></span><span>Images</span></div>
-            <div class="dm-nav-item" data-section="compose"><span class="ic ic-stacks"></span><span>Compose Stacks</span></div>
-            <div class="dm-nav-item" data-section="registries"><span class="ic ic-register"></span><span>Registries</span></div>
+            <div class="dm-brand"><img src="img/icon.svg" alt=""> <span locale="app/title">Docker Manager</span></div>
+            <div class="dm-nav-item active" data-section="containers"><span class="ic ic-container"></span><span locale="nav/containers">Containers</span></div>
+            <div class="dm-nav-item" data-section="images"><span class="ic ic-images"></span><span locale="nav/images">Images</span></div>
+            <div class="dm-nav-item" data-section="compose"><span class="ic ic-stacks"></span><span locale="nav/compose">Compose Stacks</span></div>
+            <div class="dm-nav-item" data-section="registries"><span class="ic ic-register"></span><span locale="nav/registries">Registries</span></div>
             <div class="dm-side-spacer"></div>
-            <div class="dm-side-foot">Admin-only. Docker access is root-equivalent on this host.</div>
+            <div class="dm-side-foot" locale="nav/adminNote">Admin-only. Docker access is root-equivalent on this host.</div>
         </nav>
 
         <main class="dm-main">
             <!-- Containers -->
             <section class="dm-section active" data-section="containers">
                 <div class="dm-section-head">
-                    <h2>Containers</h2>
-                    <div class="actions"><button class="btn sm" id="dm-c-refresh"><span class="ic ic-refresh"></span> Refresh</button></div>
+                    <h2 locale="nav/containers">Containers</h2>
+                    <div class="actions"><button class="btn sm" id="dm-c-refresh"><span class="ic ic-refresh"></span> <span locale="common/refresh">Refresh</span></button></div>
                 </div>
                 <div class="dm-stats">
                     <div class="stat-card">
-                        <div class="sc-head"><span class="ic ic-cpu"></span> CPU</div>
+                        <div class="sc-head"><span class="ic ic-cpu"></span> <span locale="stats/cpu">CPU</span></div>
                         <div class="sc-val" id="st-cpu">—</div>
-                        <div class="sc-sub" id="st-cpu-sub">— cores</div>
+                        <div class="sc-sub" id="st-cpu-sub">—</div>
                         <svg class="spark" id="sp-cpu" viewBox="0 0 100 24" preserveAspectRatio="none"></svg>
                     </div>
                     <div class="stat-card">
-                        <div class="sc-head"><span class="ic ic-ram"></span> RAM</div>
+                        <div class="sc-head"><span class="ic ic-ram"></span> <span locale="stats/ram">RAM</span></div>
                         <div class="sc-val" id="st-ram">—</div>
                         <div class="sc-sub" id="st-ram-sub">—</div>
                         <svg class="spark" id="sp-ram" viewBox="0 0 100 24" preserveAspectRatio="none"></svg>
                     </div>
                     <div class="stat-card">
-                        <div class="sc-head"><span class="ic ic-storage"></span> Storage</div>
+                        <div class="sc-head"><span class="ic ic-storage"></span> <span locale="stats/storage">Storage</span></div>
                         <div class="sc-val" id="st-storage">—</div>
                         <div class="sc-sub" id="st-storage-sub">—</div>
                         <svg class="spark" id="sp-storage" viewBox="0 0 100 24" preserveAspectRatio="none"></svg>
                     </div>
                     <div class="stat-card">
-                        <div class="sc-head"><span class="ic ic-load"></span> Load</div>
+                        <div class="sc-head"><span class="ic ic-load"></span> <span locale="stats/load">Load</span></div>
                         <div class="sc-val" id="st-load">—</div>
                         <div class="sc-sub" id="st-load-sub">—</div>
                         <svg class="spark" id="sp-load" viewBox="0 0 100 24" preserveAspectRatio="none"></svg>
                     </div>
                 </div>
                 <div class="dm-card"><div class="dm-tablewrap"><table class="dm-table">
-                    <thead><tr><th style="width:130px;">Status</th><th>Container</th><th style="width:200px;">Actions</th></tr></thead>
-                    <tbody id="dm-c-body"><tr><td colspan="3" class="dm-empty">Loading…</td></tr></tbody>
+                    <thead><tr><th style="width:130px;" locale="table/status">Status</th><th locale="table/container">Container</th><th style="width:200px;" locale="table/actions">Actions</th></tr></thead>
+                    <tbody id="dm-c-body"><tr><td colspan="3" class="dm-empty" locale="common/loading">Loading…</td></tr></tbody>
                 </table></div></div>
-                <div class="note">Click a row to open full container details.</div>
+                <div class="note" locale="containers/rowHint">Click a row to open full container details.</div>
             </section>
 
             <!-- Images -->
             <section class="dm-section" data-section="images">
                 <div class="dm-section-head">
-                    <h2>Images</h2>
+                    <h2 locale="nav/images">Images</h2>
                     <div class="actions">
-                        <span class="inline-input"><input type="text" class="fld" id="dm-pull-ref" placeholder="nginx:alpine or registry/repo:tag"><button class="btn primary" id="dm-pull-btn"><span class="ic ic-download"></span> Pull</button></span>
-                        <button class="btn sm" id="dm-i-refresh"><span class="ic ic-refresh"></span> Refresh</button>
+                        <span class="inline-input"><input type="text" class="fld" id="dm-pull-ref" placeholder="nginx:alpine or registry/repo:tag"><button class="btn primary" id="dm-pull-btn"><span class="ic ic-download"></span> <span locale="images/pull">Pull</span></button></span>
+                        <button class="btn sm" id="dm-i-refresh"><span class="ic ic-refresh"></span> <span locale="common/refresh">Refresh</span></button>
                     </div>
                 </div>
                 <div class="dm-card"><div class="dm-tablewrap"><table class="dm-table">
-                    <thead><tr><th>Repository</th><th>Tag</th><th>Size</th><th>Created</th><th>Image ID</th><th style="width:90px;">Actions</th></tr></thead>
-                    <tbody id="dm-i-body"><tr><td colspan="6" class="dm-empty">Loading…</td></tr></tbody>
+                    <thead><tr><th locale="table/repository">Repository</th><th locale="table/tag">Tag</th><th locale="table/size">Size</th><th locale="table/created">Created</th><th locale="table/imageId">Image ID</th><th style="width:90px;" locale="table/actions">Actions</th></tr></thead>
+                    <tbody id="dm-i-body"><tr><td colspan="6" class="dm-empty" locale="common/loading">Loading…</td></tr></tbody>
                 </table></div></div>
             </section>
 
             <!-- Compose -->
             <section class="dm-section" data-section="compose">
                 <div class="dm-section-head">
-                    <h2>Compose Stacks</h2>
+                    <h2 locale="nav/compose">Compose Stacks</h2>
                     <div class="actions">
-                        <button class="btn primary sm" id="dm-stack-new"><span class="ic ic-addbox"></span> New Stack</button>
-                        <button class="btn sm" id="dm-stack-refresh"><span class="ic ic-refresh"></span> Refresh</button>
+                        <button class="btn primary sm" id="dm-stack-new"><span class="ic ic-addbox"></span> <span locale="compose/newStack">New Stack</span></button>
+                        <button class="btn sm" id="dm-stack-refresh"><span class="ic ic-refresh"></span> <span locale="common/refresh">Refresh</span></button>
                     </div>
                 </div>
-                <div id="dm-compose-unavail" class="msg warn" style="display:none; margin-bottom:14px;"><span class="ic ic-warning"></span><div>The Docker Compose v2 plugin was not detected on this host. Install <code>docker-compose-plugin</code> to manage stacks.</div></div>
+                <div id="dm-compose-unavail" class="msg warn" style="display:none; margin-bottom:14px;"><span class="ic ic-warning"></span><div locale="compose/unavailable">The Docker Compose v2 plugin was not detected on this host. Install <code>docker-compose-plugin</code> to manage stacks.</div></div>
                 <div class="dm-card" id="dm-stack-card"><div class="dm-tablewrap"><table class="dm-table">
-                    <thead><tr><th>Stack</th><th>State</th><th>Auto-start</th><th style="width:230px;">Actions</th></tr></thead>
-                    <tbody id="dm-stack-body"><tr><td colspan="4" class="dm-empty">Loading…</td></tr></tbody>
+                    <thead><tr><th locale="table/stack">Stack</th><th locale="table/state">State</th><th locale="table/autostart">Auto-start</th><th style="width:230px;" locale="table/actions">Actions</th></tr></thead>
+                    <tbody id="dm-stack-body"><tr><td colspan="4" class="dm-empty" locale="common/loading">Loading…</td></tr></tbody>
                 </table></div></div>
-                <div class="note">Stacks live in <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>. Stacks with auto-start enabled are brought up on server boot.</div>
+                <div class="note" locale="compose/note">Stacks live in <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>. Stacks with auto-start enabled are brought up on server boot.</div>
             </section>
 
             <!-- Registries -->
             <section class="dm-section" data-section="registries">
                 <div class="dm-section-head">
-                    <h2>Registries</h2>
-                    <div class="actions"><button class="btn sm" id="dm-r-refresh"><span class="ic ic-refresh"></span> Refresh</button></div>
+                    <h2 locale="nav/registries">Registries</h2>
+                    <div class="actions"><button class="btn sm" id="dm-r-refresh"><span class="ic ic-refresh"></span> <span locale="common/refresh">Refresh</span></button></div>
                 </div>
                 <div style="display:flex; gap:16px; flex-wrap:wrap; align-items:flex-start;">
                     <div class="dm-card" style="flex:1; min-width:280px; padding:18px;">
-                        <div style="font-weight:650; margin-bottom:12px;">Login</div>
-                        <div class="field2"><label class="lbl">Registry (blank = Docker Hub)</label><input type="text" class="fld" id="dm-login-registry" placeholder="registry.example.com:5000"></div>
-                        <div class="field2"><label class="lbl">Username</label><input type="text" class="fld" id="dm-login-user" autocomplete="off"></div>
-                        <div class="field2"><label class="lbl">Password / Token</label><input type="password" class="fld" id="dm-login-pass" autocomplete="off"></div>
-                        <button class="btn primary" id="dm-login-btn">Login</button>
-                        <div class="note">Credentials are stored by the Docker CLI, never by ArozOS. The password is sent once and never logged.</div>
-                        <div style="font-weight:650; margin:16px 0 8px;">Configured registries</div>
-                        <table class="dm-table" style="border:1px solid var(--border); border-radius:8px;"><tbody id="dm-r-body"><tr><td class="dm-empty">Loading…</td></tr></tbody></table>
+                        <div style="font-weight:650; margin-bottom:12px;" locale="registry/loginTitle">Login</div>
+                        <div class="field2"><label class="lbl" locale="registry/registry">Registry (blank = Docker Hub)</label><input type="text" class="fld" id="dm-login-registry" placeholder="registry.example.com:5000"></div>
+                        <div class="field2"><label class="lbl" locale="registry/username">Username</label><input type="text" class="fld" id="dm-login-user" autocomplete="off"></div>
+                        <div class="field2"><label class="lbl" locale="registry/password">Password / Token</label><input type="password" class="fld" id="dm-login-pass" autocomplete="off"></div>
+                        <button class="btn primary" id="dm-login-btn"><span locale="registry/login">Login</span></button>
+                        <div class="note" locale="registry/credNote">Credentials are stored by the Docker CLI, never by ArozOS. The password is sent once and never logged.</div>
+                        <div style="font-weight:650; margin:16px 0 8px;" locale="registry/configured">Configured registries</div>
+                        <table class="dm-table" style="border:1px solid var(--border); border-radius:8px;"><tbody id="dm-r-body"><tr><td class="dm-empty" locale="common/loading">Loading…</td></tr></tbody></table>
                     </div>
                     <div class="dm-card" style="flex:1; min-width:280px; padding:18px;">
-                        <div style="font-weight:650; margin-bottom:12px;">Search Docker Hub</div>
-                        <span class="inline-input" style="width:100%;"><input type="text" class="fld" id="dm-search-term" placeholder="e.g. nginx" style="flex:1;"><button class="btn" id="dm-search-btn"><span class="ic ic-search"></span> Search</button></span>
+                        <div style="font-weight:650; margin-bottom:12px;" locale="registry/searchTitle">Search Docker Hub</div>
+                        <span class="inline-input" style="width:100%;"><input type="text" class="fld" id="dm-search-term" placeholder="e.g. nginx" style="flex:1;"><button class="btn" id="dm-search-btn"><span class="ic ic-search"></span> <span locale="registry/search">Search</span></button></span>
                         <table class="dm-table" style="margin-top:14px; border:1px solid var(--border); border-radius:8px;">
-                            <thead><tr><th>Name</th><th>Stars</th><th style="width:70px;"></th></tr></thead>
-                            <tbody id="dm-search-body"><tr><td colspan="3" class="dm-empty">Enter a term and search.</td></tr></tbody>
+                            <thead><tr><th locale="table/name">Name</th><th locale="table/stars">Stars</th><th style="width:70px;"></th></tr></thead>
+                            <tbody id="dm-search-body"><tr><td colspan="3" class="dm-empty" locale="registry/searchHint">Enter a term and search.</td></tr></tbody>
                         </table>
                     </div>
                 </div>
@@ -352,97 +353,97 @@
 
     <!-- Container details -->
     <aside class="drawer wide" id="dm-details-drawer">
-        <div class="drawer-head"><span class="ic ic-container"></span> <span class="ttl">Container: <span id="dm-details-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-container"></span> <span class="ttl"><span locale="drawer/container">Container:</span> <span id="dm-details-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body" id="dm-details-body"></div>
     </aside>
 
     <!-- Logs (container + stack) -->
     <aside class="drawer wide" id="dm-log-drawer">
-        <div class="drawer-head"><span class="ic ic-logs"></span> <span class="ttl">Logs: <span id="dm-log-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-logs"></span> <span class="ttl"><span locale="drawer/logs">Logs:</span> <span id="dm-log-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body term"><pre id="dm-log-output" class="term-pre"></pre></div>
     </aside>
 
     <!-- Pull progress -->
     <aside class="drawer wide" id="dm-pull-drawer">
-        <div class="drawer-head"><span class="ic ic-download"></span> <span class="ttl">Pulling: <span id="dm-pull-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-download"></span> <span class="ttl"><span locale="drawer/pulling">Pulling:</span> <span id="dm-pull-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body term"><pre id="dm-log-output-pull" class="term-pre"></pre></div>
     </aside>
 
     <!-- Run Image / Recreate -->
     <aside class="drawer" id="dm-run-drawer">
-        <div class="drawer-head"><span class="ic ic-start"></span> <span class="ttl" id="dm-run-title">Run Image</span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-start"></span> <span class="ttl" id="dm-run-title" locale="run/title">Run Image</span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body">
-            <div class="msg warn" id="dm-run-warn" style="display:none; margin-bottom:14px;"><span class="ic ic-warning"></span><div><b>Recreate</b> deletes the existing container and launches a new one with these settings. This cannot be undone.</div></div>
+            <div class="msg warn" id="dm-run-warn" style="display:none; margin-bottom:14px;"><span class="ic ic-warning"></span><div locale="run/recreateWarn"><b>Recreate</b> deletes the existing container and launches a new one with these settings. This cannot be undone.</div></div>
             <div class="row2">
-                <div class="field2"><label class="lbl">Image</label><input type="text" class="fld" id="dm-run-image" placeholder="nginx:alpine"></div>
-                <div class="field2"><label class="lbl">Container name (optional)</label><input type="text" class="fld" id="dm-run-name" placeholder="web1"></div>
+                <div class="field2"><label class="lbl" locale="run/image">Image</label><input type="text" class="fld" id="dm-run-image" placeholder="nginx:alpine"></div>
+                <div class="field2"><label class="lbl" locale="run/name">Container name (optional)</label><input type="text" class="fld" id="dm-run-name" placeholder="web1"></div>
             </div>
             <div class="row2">
-                <div class="field2"><label class="lbl">Port mappings (one per line)</label><textarea class="fld" id="dm-run-ports" rows="3" placeholder="8080:80&#10;127.0.0.1:443:443/tcp"></textarea></div>
-                <div class="field2"><label class="lbl">Volumes (src:dst[:mode])</label><textarea class="fld" id="dm-run-volumes" rows="3" placeholder="/srv/data:/data&#10;named:/var/lib:ro"></textarea></div>
+                <div class="field2"><label class="lbl" locale="run/ports">Port mappings (one per line)</label><textarea class="fld" id="dm-run-ports" rows="3" placeholder="8080:80&#10;127.0.0.1:443:443/tcp"></textarea></div>
+                <div class="field2"><label class="lbl" locale="run/volumes">Volumes (src:dst[:mode])</label><textarea class="fld" id="dm-run-volumes" rows="3" placeholder="/srv/data:/data&#10;named:/var/lib:ro"></textarea></div>
             </div>
-            <div class="field2"><label class="lbl">Environment variables (KEY=value per line)</label><textarea class="fld" id="dm-run-env" rows="2" placeholder="TZ=UTC&#10;APP_DEBUG=1"></textarea></div>
+            <div class="field2"><label class="lbl" locale="run/env">Environment variables (KEY=value per line)</label><textarea class="fld" id="dm-run-env" rows="2" placeholder="TZ=UTC&#10;APP_DEBUG=1"></textarea></div>
             <div class="row2">
-                <div class="field2"><label class="lbl">Restart policy</label><select class="fld" id="dm-run-restart"><option value="">no</option><option value="always">always</option><option value="unless-stopped">unless-stopped</option><option value="on-failure">on-failure</option></select></div>
-                <div class="field2"><label class="lbl">Network (optional)</label><input type="text" class="fld" id="dm-run-network" placeholder="bridge"></div>
+                <div class="field2"><label class="lbl" locale="run/restart">Restart policy</label><select class="fld" id="dm-run-restart"><option value="">no</option><option value="always">always</option><option value="unless-stopped">unless-stopped</option><option value="on-failure">on-failure</option></select></div>
+                <div class="field2"><label class="lbl" locale="run/network">Network (optional)</label><input type="text" class="fld" id="dm-run-network" placeholder="bridge"></div>
             </div>
-            <div class="field2"><label class="lbl">Command override (optional)</label><input type="text" class="fld" id="dm-run-command" placeholder="nginx -g 'daemon off;'"></div>
+            <div class="field2"><label class="lbl" locale="run/command">Command override (optional)</label><input type="text" class="fld" id="dm-run-command" placeholder="nginx -g 'daemon off;'"></div>
         </div>
         <div class="drawer-foot">
-            <button class="btn drawer-close">Cancel</button>
-            <button class="btn" id="dm-run-createonly">Create (don't start)</button>
-            <button class="btn primary" id="dm-run-go"><span class="ic ic-start"></span> <span id="dm-run-gotext">Run</span></button>
+            <button class="btn drawer-close" locale="common/cancel">Cancel</button>
+            <button class="btn" id="dm-run-createonly" locale="run/createOnly">Create (don't start)</button>
+            <button class="btn primary" id="dm-run-go"><span class="ic ic-start"></span> <span id="dm-run-gotext" locale="run/run">Run</span></button>
         </div>
     </aside>
 
     <!-- Container config -->
     <aside class="drawer" id="dm-cfg-drawer">
-        <div class="drawer-head"><span class="ic ic-setting"></span> <span class="ttl">Configure: <span id="dm-cfg-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-setting"></span> <span class="ttl"><span locale="drawer/configure">Configure:</span> <span id="dm-cfg-title" class="dm-mono"></span></span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body">
-            <div class="field2"><label class="lbl">Rename</label><span class="inline-input" style="width:100%;"><input type="text" class="fld" id="dm-cfg-name" style="flex:1;"><button class="btn" id="dm-cfg-rename">Rename</button></span></div>
+            <div class="field2"><label class="lbl" locale="cfg/rename">Rename</label><span class="inline-input" style="width:100%;"><input type="text" class="fld" id="dm-cfg-name" style="flex:1;"><button class="btn" id="dm-cfg-rename"><span locale="cfg/rename">Rename</span></button></span></div>
             <div style="height:1px; background:var(--border); margin:16px 0;"></div>
-            <div style="font-weight:600; margin-bottom:10px;">Live update <span class="note" style="display:inline;">(applied without recreating)</span></div>
+            <div style="font-weight:600; margin-bottom:10px;"><span locale="cfg/liveUpdate">Live update</span> <span class="note" style="display:inline;" locale="cfg/liveUpdateNote">(applied without recreating)</span></div>
             <div class="row2">
-                <div class="field2"><label class="lbl">Restart policy</label><select class="fld" id="dm-cfg-restart"><option value="">(unchanged)</option><option value="no">no</option><option value="always">always</option><option value="unless-stopped">unless-stopped</option><option value="on-failure">on-failure</option></select></div>
-                <div class="field2"><label class="lbl">Memory limit</label><input type="text" class="fld" id="dm-cfg-memory" placeholder="512m"></div>
-                <div class="field2"><label class="lbl">CPUs</label><input type="text" class="fld" id="dm-cfg-cpus" placeholder="1.5"></div>
+                <div class="field2"><label class="lbl" locale="run/restart">Restart policy</label><select class="fld" id="dm-cfg-restart"><option value="" locale="cfg/unchanged">(unchanged)</option><option value="no">no</option><option value="always">always</option><option value="unless-stopped">unless-stopped</option><option value="on-failure">on-failure</option></select></div>
+                <div class="field2"><label class="lbl" locale="cfg/memory">Memory limit</label><input type="text" class="fld" id="dm-cfg-memory" placeholder="512m"></div>
+                <div class="field2"><label class="lbl" locale="cfg/cpus">CPUs</label><input type="text" class="fld" id="dm-cfg-cpus" placeholder="1.5"></div>
             </div>
-            <div class="msg info" style="margin-top:6px;"><div>Changing ports, volumes or environment needs a <b>recreate</b>. <a id="dm-cfg-recreate" style="cursor:pointer; font-weight:600; color:var(--accent);">Recreate with new settings…</a></div></div>
+            <div class="msg info" style="margin-top:6px;"><div><span locale="cfg/recreateNote">Changing ports, volumes or environment needs a <b>recreate</b>.</span> <a id="dm-cfg-recreate" style="cursor:pointer; font-weight:600; color:var(--accent);" locale="cfg/recreateLink">Recreate with new settings…</a></div></div>
         </div>
-        <div class="drawer-foot"><button class="btn drawer-close">Close</button><button class="btn primary" id="dm-cfg-apply">Apply live update</button></div>
+        <div class="drawer-foot"><button class="btn drawer-close" locale="common/close">Close</button><button class="btn primary" id="dm-cfg-apply"><span locale="cfg/apply">Apply live update</span></button></div>
     </aside>
 
     <!-- Compose YAML editor -->
     <aside class="drawer wide" id="dm-stack-drawer">
-        <div class="drawer-head"><span class="ic ic-edit"></span> <span class="ttl" id="dm-stack-modal-title">New Stack</span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
+        <div class="drawer-head"><span class="ic ic-edit"></span> <span class="ttl" id="dm-stack-modal-title" locale="compose/newStack">New Stack</span><div class="head-actions"><div class="drawer-close"><span class="ic ic-close"></span></div></div></div>
         <div class="drawer-body">
-            <div class="field2"><label class="lbl">Stack name (letters, digits, dash, underscore)</label><input type="text" class="fld" id="dm-stack-name" placeholder="my-stack"></div>
+            <div class="field2"><label class="lbl" locale="compose/nameLabel">Stack name (letters, digits, dash, underscore)</label><input type="text" class="fld" id="dm-stack-name" placeholder="my-stack"></div>
             <div class="field2"><label class="lbl">docker-compose.yml</label><div id="dm-stack-editor" style="height:48vh; width:100%; border:1px solid var(--border); border-radius:8px;"></div></div>
             <div class="msg" id="dm-stack-validate-msg" style="display:none;"></div>
         </div>
         <div class="drawer-foot">
             <span class="status" id="dm-stack-status"></span>
-            <button class="btn drawer-close">Cancel</button>
-            <button class="btn" id="dm-stack-validate">Validate</button>
-            <button class="btn" id="dm-stack-save">Save</button>
-            <button class="btn primary" id="dm-stack-savedeploy"><span class="ic ic-start"></span> Save &amp; Deploy</button>
+            <button class="btn drawer-close" locale="common/cancel">Cancel</button>
+            <button class="btn" id="dm-stack-validate"><span locale="compose/validate">Validate</span></button>
+            <button class="btn" id="dm-stack-save"><span locale="common/save">Save</span></button>
+            <button class="btn primary" id="dm-stack-savedeploy"><span class="ic ic-start"></span> <span locale="compose/saveDeploy">Save &amp; Deploy</span></button>
         </div>
     </aside>
 
     <!-- Exec console -->
     <aside class="drawer wide" id="dm-console-drawer">
-        <div class="drawer-head"><span class="ic ic-terminal"></span> <span class="ttl">Console: <span id="dm-console-title" class="dm-mono"></span></span>
+        <div class="drawer-head"><span class="ic ic-terminal"></span> <span class="ttl"><span locale="drawer/console">Console:</span> <span id="dm-console-title" class="dm-mono"></span></span>
             <div class="head-actions">
                 <div class="drawer-close" id="dm-console-popout" title="Open in window" style="display:none;"><span class="ic ic-popout"></span></div>
                 <div class="drawer-close"><span class="ic ic-close"></span></div>
             </div>
         </div>
         <div class="drawer-body term">
-            <div class="console-warn"><span class="ic ic-warning"></span><div>You are connected to a <b>live shell inside this container</b>. Commands run with the container's privileges and can affect the host.</div></div>
+            <div class="console-warn"><span class="ic ic-warning"></span><div locale="console/warn">You are connected to a <b>live shell inside this container</b>. Commands run with the container's privileges and can affect the host.</div></div>
             <div class="console-bar">
-                <label>Shell:</label>
+                <label locale="console/shell">Shell:</label>
                 <select class="fld" id="dm-console-shell" style="width:90px; padding:5px 8px;"><option value="sh">sh</option><option value="bash">bash</option></select>
-                <button class="btn sm" id="dm-console-connect">Reconnect</button>
+                <button class="btn sm" id="dm-console-connect"><span locale="console/reconnect">Reconnect</span></button>
             </div>
             <div id="dm-console-term"></div>
         </div>
@@ -451,8 +452,11 @@
     <div id="dm-toast"></div>
 
     <script>
-    (function () {
-        DM.applyTheme();
+    DM.applyTheme();
+    DM.initLocale("locale/dockermanager.json", startDockerManager);
+
+    function startDockerManager() {
+        var T = DM.t;
         var inDesktop = (typeof ao_module_virtualDesktop !== "undefined") && ao_module_virtualDesktop;
 
         /* progress / busy */
@@ -494,51 +498,52 @@
         function stateBadge(state) {
             var s = (state || "").toLowerCase();
             var cls = s === "running" ? "green" : (s === "paused" || s === "restarting" || s === "created" ? "amber" : "gray");
-            return '<span class="pill ' + cls + '"><span class="dot"></span>' + DM.esc(state || "-") + '</span>';
+            return '<span class="pill ' + cls + '"><span class="dot"></span>' + DM.esc(state ? T("state/" + s, state) : "-") + '</span>';
         }
         function actionBtns(c) {
             var running = (c.State || "").toLowerCase() === "running";
             var ref = DM.esc(c.ID || c.Names), nm = DM.esc(c.Names);
             var h = '<div class="btnrow">';
             if (running) {
-                h += '<div class="iconbtn" title="Stop" data-act="stop" data-id="' + ref + '"><span class="ic ic-stop"></span></div>';
-                h += '<div class="iconbtn" title="Restart" data-act="restart" data-id="' + ref + '"><span class="ic ic-refresh"></span></div>';
-                h += '<div class="iconbtn" title="Console" data-act="console" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-terminal"></span></div>';
+                h += '<div class="iconbtn" title="' + T("act/stop", "Stop") + '" data-act="stop" data-id="' + ref + '"><span class="ic ic-stop"></span></div>';
+                h += '<div class="iconbtn" title="' + T("act/restart", "Restart") + '" data-act="restart" data-id="' + ref + '"><span class="ic ic-refresh"></span></div>';
+                h += '<div class="iconbtn" title="' + T("act/console", "Console") + '" data-act="console" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-terminal"></span></div>';
             } else {
-                h += '<div class="iconbtn green" title="Start" data-act="start" data-id="' + ref + '"><span class="ic ic-start"></span></div>';
+                h += '<div class="iconbtn green" title="' + T("act/start", "Start") + '" data-act="start" data-id="' + ref + '"><span class="ic ic-start"></span></div>';
             }
-            h += '<div class="iconbtn" title="Logs" data-act="logs" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-logs"></span></div>';
-            h += '<div class="iconbtn" title="Publish as app" data-act="publish" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-publish"></span></div>';
-            h += '<div class="iconbtn" title="Configure" data-act="config" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-setting"></span></div>';
-            h += '<div class="iconbtn danger" title="Remove" data-act="remove" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-delete"></span></div>';
+            h += '<div class="iconbtn" title="' + T("act/logs", "Logs") + '" data-act="logs" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-logs"></span></div>';
+            h += '<div class="iconbtn" title="' + T("act/publish", "Publish as app") + '" data-act="publish" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-publish"></span></div>';
+            h += '<div class="iconbtn" title="' + T("act/config", "Configure") + '" data-act="config" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-setting"></span></div>';
+            h += '<div class="iconbtn danger" title="' + T("act/remove", "Remove") + '" data-act="remove" data-id="' + ref + '" data-name="' + nm + '"><span class="ic ic-delete"></span></div>';
             h += '</div>';
             return h;
         }
         function loadContainers() {
-            $cBody.html('<tr><td colspan="3" class="dm-empty">Loading…</td></tr>');
+            $cBody.html('<tr><td colspan="3" class="dm-empty">' + T("common/loading", "Loading…") + '</td></tr>');
             track(DM.get("containers/list", function (list) {
                 if (list && list.error) { $cBody.html('<tr><td colspan="3" class="dm-empty">' + DM.esc(list.error) + '</td></tr>'); return; }
-                if (!list || list.length === 0) { $cBody.html('<tr><td colspan="3" class="dm-empty">No containers found.</td></tr>'); return; }
+                if (!list || list.length === 0) { $cBody.html('<tr><td colspan="3" class="dm-empty">' + T("containers/none", "No containers found.") + '</td></tr>'); return; }
                 $cBody.html(list.map(function (c) {
                     return '<tr class="clickable" data-cid="' + DM.esc(c.ID || c.Names) + '" data-cname="' + DM.esc(c.Names) + '">' +
                         '<td>' + stateBadge(c.State) + '</td>' +
                         '<td><div class="dm-mono">' + DM.esc(c.Names) + '</div><div class="dm-mono" style="color:var(--dim);font-size:10.5px;">' + DM.esc(c.Image) + '</div></td>' +
                         '<td>' + actionBtns(c) + '</td></tr>';
                 }).join(""));
-            }, function () { $cBody.html('<tr><td colspan="3" class="dm-empty">Failed to load (administrator access required).</td></tr>'); }));
+            }, function () { $cBody.html('<tr><td colspan="3" class="dm-empty">' + T("containers/loadFailed", "Failed to load (administrator access required).") + '</td></tr>'); }));
         }
+        function verb(act) { return T("verb/" + act, act); }
         function doAction(act, id, $btn) {
             track(DM.post("containers/" + act, { id: id }, function (r) {
-                if (r && r.error) { DM.toast(act + " failed: " + r.error, false); return; }
-                DM.toast("Container " + act + " ok.", true); loadContainers();
-            }, function () { DM.toast(act + " request failed.", false); }), $btn);
+                if (r && r.error) { DM.toast(T("toast/actFailed", "{action} failed: {error}", { action: verb(act), error: r.error }), false); return; }
+                DM.toast(T("toast/containerOk", "Container {action} ok.", { action: verb(act) }), true); loadContainers();
+            }, function () { DM.toast(T("toast/actRequestFailed", "{action} request failed.", { action: verb(act) }), false); }), $btn);
         }
         function handleContainerAction(act, id, name, $btn) {
             if (act === "logs") { openLogs(id, name); return; }
             if (act === "console") { openConsole(id, name); return; }
             if (act === "config") { openConfig(id, name); return; }
             if (act === "publish") { publishApp(name || id); return; }
-            if (act === "remove") { if (!confirm("Remove container \"" + (name || id) + "\"? This force-stops and deletes it. This cannot be undone.")) return; doAction("remove", id, $btn); return; }
+            if (act === "remove") { if (!confirm(T("containers/confirmRemove", "Remove container \"{name}\"? This force-stops and deletes it. This cannot be undone.", { name: name || id }))) return; doAction("remove", id, $btn); return; }
             doAction(act, id, $btn);
         }
 
@@ -550,7 +555,7 @@
                 width: 900,
                 height: 640,
                 appicon: "ContainerApps/img/icon.svg",
-                title: "Container Apps"
+                title: T("publish/appTitle", "Container Apps")
             });
         }
 
@@ -563,15 +568,15 @@
             try {
                 logSocket = new WebSocket(DM.wsURL(wsPath));
                 logSocket.onmessage = function (ev) { var o = document.getElementById("dm-log-output"); var b = o.scrollTop + o.clientHeight >= o.scrollHeight - 20; o.textContent += ev.data; if (b) o.scrollTop = o.scrollHeight; };
-                logSocket.onerror = function () { DM.toast("Log stream error.", false); };
-            } catch (e) { DM.toast("Unable to open log stream.", false); }
+                logSocket.onerror = function () { DM.toast(T("logs/streamError", "Log stream error."), false); };
+            } catch (e) { DM.toast(T("logs/openFailed", "Unable to open log stream."), false); }
         }
         function openLogs(id, name) { streamLog(name || id, "containers/logs?id=" + encodeURIComponent(id)); }
 
         /* details */
         function dkv(k, v) { return '<div class="d-kv"><div class="d-k">' + k + '</div><div class="d-v">' + (v || '<span style="color:var(--dim)">—</span>') + '</div></div>'; }
         function renderDetails(d, name, ref) {
-            if (!d) { return '<div class="dm-empty">No data.</div>'; }
+            if (!d) { return '<div class="dm-empty">' + T("details/noData", "No data.") + '</div>'; }
             var idShort = (d.Id || "").substring(0, 12);
             var running = d.State && d.State.Running;
             var cmd = (d.Config && d.Config.Cmd) ? d.Config.Cmd.join(" ") : "";
@@ -579,46 +584,46 @@
             // action bar
             html += '<div class="d-actions">';
             if (running) {
-                html += '<button class="btn sm" data-dact="stop" data-id="' + DM.esc(ref) + '"><span class="ic ic-stop"></span> Stop</button>';
-                html += '<button class="btn sm" data-dact="restart" data-id="' + DM.esc(ref) + '"><span class="ic ic-refresh"></span> Restart</button>';
-                html += '<button class="btn sm" data-dact="console" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-terminal"></span> Console</button>';
+                html += '<button class="btn sm" data-dact="stop" data-id="' + DM.esc(ref) + '"><span class="ic ic-stop"></span> ' + T("act/stop", "Stop") + '</button>';
+                html += '<button class="btn sm" data-dact="restart" data-id="' + DM.esc(ref) + '"><span class="ic ic-refresh"></span> ' + T("act/restart", "Restart") + '</button>';
+                html += '<button class="btn sm" data-dact="console" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-terminal"></span> ' + T("act/console", "Console") + '</button>';
             } else {
-                html += '<button class="btn sm" data-dact="start" data-id="' + DM.esc(ref) + '"><span class="ic ic-start"></span> Start</button>';
+                html += '<button class="btn sm" data-dact="start" data-id="' + DM.esc(ref) + '"><span class="ic ic-start"></span> ' + T("act/start", "Start") + '</button>';
             }
-            html += '<button class="btn sm" data-dact="logs" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-logs"></span> Logs</button>';
-            html += '<button class="btn sm" data-dact="config" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-setting"></span> Configure</button>';
-            html += '<button class="btn sm danger" data-dact="remove" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-delete"></span> Remove</button>';
+            html += '<button class="btn sm" data-dact="logs" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-logs"></span> ' + T("act/logs", "Logs") + '</button>';
+            html += '<button class="btn sm" data-dact="config" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-setting"></span> ' + T("act/config", "Configure") + '</button>';
+            html += '<button class="btn sm danger" data-dact="remove" data-id="' + DM.esc(ref) + '" data-name="' + DM.esc(name) + '"><span class="ic ic-delete"></span> ' + T("act/remove", "Remove") + '</button>';
             html += '</div>';
             // overview
             html += '<div class="d-grid">';
-            html += dkv("State", stateBadge(d.State && d.State.Status));
-            html += dkv("Image", '<span class="dm-mono">' + DM.esc((d.Config && d.Config.Image) || "") + '</span>');
-            html += dkv("Container ID", '<span class="dm-mono">' + DM.esc(idShort) + '</span>');
-            html += dkv("Created", DM.esc((d.Created || "").replace("T", " ").substring(0, 19)));
-            html += dkv("Restart Policy", DM.esc((d.HostConfig && d.HostConfig.RestartPolicy && d.HostConfig.RestartPolicy.Name) || "no"));
-            html += dkv("Started", DM.esc((d.State && d.State.StartedAt || "").replace("T", " ").substring(0, 19)));
+            html += dkv(T("details/state", "State"), stateBadge(d.State && d.State.Status));
+            html += dkv(T("details/image", "Image"), '<span class="dm-mono">' + DM.esc((d.Config && d.Config.Image) || "") + '</span>');
+            html += dkv(T("details/containerId", "Container ID"), '<span class="dm-mono">' + DM.esc(idShort) + '</span>');
+            html += dkv(T("details/created", "Created"), DM.esc((d.Created || "").replace("T", " ").substring(0, 19)));
+            html += dkv(T("details/restartPolicy", "Restart Policy"), DM.esc((d.HostConfig && d.HostConfig.RestartPolicy && d.HostConfig.RestartPolicy.Name) || "no"));
+            html += dkv(T("details/started", "Started"), DM.esc((d.State && d.State.StartedAt || "").replace("T", " ").substring(0, 19)));
             html += '</div>';
-            if (cmd) { html += '<div class="d-sec-title">Command</div><div class="env-list">' + DM.esc(cmd) + '</div>'; }
+            if (cmd) { html += '<div class="d-sec-title">' + T("details/command", "Command") + '</div><div class="env-list">' + DM.esc(cmd) + '</div>'; }
             // ports
             var ports = (d.NetworkSettings && d.NetworkSettings.Ports) || {};
             var pkeys = Object.keys(ports);
-            html += '<div class="d-sec-title">Ports</div>';
-            if (pkeys.length === 0) { html += '<div class="note">No exposed ports.</div>'; }
+            html += '<div class="d-sec-title">' + T("details/ports", "Ports") + '</div>';
+            if (pkeys.length === 0) { html += '<div class="note">' + T("details/noPorts", "No exposed ports.") + '</div>'; }
             else {
-                html += '<table class="d-table"><thead><tr><th>Container Port</th><th>Protocol</th><th>Published To (host)</th></tr></thead><tbody>';
+                html += '<table class="d-table"><thead><tr><th>' + T("details/containerPort", "Container Port") + '</th><th>' + T("details/protocol", "Protocol") + '</th><th>' + T("details/publishedTo", "Published To (host)") + '</th></tr></thead><tbody>';
                 pkeys.forEach(function (cp) {
                     var parts = cp.split("/"); var binds = ports[cp];
-                    var to = (binds && binds.length) ? binds.map(function (b) { return DM.esc((b.HostIp || "") + ":" + (b.HostPort || "")); }).join("<br>") : '<span style="color:var(--dim)">not published</span>';
+                    var to = (binds && binds.length) ? binds.map(function (b) { return DM.esc((b.HostIp || "") + ":" + (b.HostPort || "")); }).join("<br>") : '<span style="color:var(--dim)">' + T("details/notPublished", "not published") + '</span>';
                     html += '<tr><td class="dm-mono">' + DM.esc(parts[0]) + '</td><td>' + DM.esc(parts[1] || "tcp") + '</td><td class="dm-mono">' + to + '</td></tr>';
                 });
                 html += '</tbody></table>';
             }
             // mounts
             var mounts = d.Mounts || [];
-            html += '<div class="d-sec-title">Mounts</div>';
-            if (mounts.length === 0) { html += '<div class="note">No mounts.</div>'; }
+            html += '<div class="d-sec-title">' + T("details/mounts", "Mounts") + '</div>';
+            if (mounts.length === 0) { html += '<div class="note">' + T("details/noMounts", "No mounts.") + '</div>'; }
             else {
-                html += '<table class="d-table"><thead><tr><th>Source</th><th>Destination</th><th>Mode</th></tr></thead><tbody>';
+                html += '<table class="d-table"><thead><tr><th>' + T("details/source", "Source") + '</th><th>' + T("details/destination", "Destination") + '</th><th>' + T("details/mode", "Mode") + '</th></tr></thead><tbody>';
                 mounts.forEach(function (m) { html += '<tr><td class="dm-mono">' + DM.esc(m.Source || m.Name || "") + '</td><td class="dm-mono">' + DM.esc(m.Destination || "") + '</td><td>' + DM.esc((m.RW === false ? "ro" : (m.Mode || "rw"))) + '</td></tr>'; });
                 html += '</tbody></table>';
             }
@@ -626,24 +631,24 @@
             var nets = (d.NetworkSettings && d.NetworkSettings.Networks) || {};
             var nkeys = Object.keys(nets);
             if (nkeys.length) {
-                html += '<div class="d-sec-title">Networks</div><table class="d-table"><thead><tr><th>Network</th><th>IP Address</th><th>Gateway</th></tr></thead><tbody>';
+                html += '<div class="d-sec-title">' + T("details/networks", "Networks") + '</div><table class="d-table"><thead><tr><th>' + T("details/network", "Network") + '</th><th>' + T("details/ip", "IP Address") + '</th><th>' + T("details/gateway", "Gateway") + '</th></tr></thead><tbody>';
                 nkeys.forEach(function (nk) { var n = nets[nk]; html += '<tr><td class="dm-mono">' + DM.esc(nk) + '</td><td class="dm-mono">' + DM.esc(n.IPAddress || "") + '</td><td class="dm-mono">' + DM.esc(n.Gateway || "") + '</td></tr>'; });
                 html += '</tbody></table>';
             }
             // env
             var env = (d.Config && d.Config.Env) || [];
-            if (env.length) { html += '<div class="d-sec-title">Environment</div><div class="env-list">' + env.map(DM.esc).join("\n") + '</div>'; }
+            if (env.length) { html += '<div class="d-sec-title">' + T("details/environment", "Environment") + '</div><div class="env-list">' + env.map(DM.esc).join("\n") + '</div>'; }
             return html;
         }
         function openDetails(id, name) {
             $("#dm-details-title").text(name || id);
-            $("#dm-details-body").html('<div class="dm-empty">Loading…</div>');
+            $("#dm-details-body").html('<div class="dm-empty">' + T("common/loading", "Loading…") + '</div>');
             openDrawer("dm-details-drawer");
             DM.get("containers/inspect?id=" + encodeURIComponent(id), function (data) {
                 if (data && data.error) { $("#dm-details-body").html('<div class="dm-empty">' + DM.esc(data.error) + '</div>'); return; }
                 var d = Array.isArray(data) ? data[0] : data;
                 $("#dm-details-body").html(renderDetails(d, name || id, id));
-            }, function () { $("#dm-details-body").html('<div class="dm-empty">Failed to load (admin only).</div>'); });
+            }, function () { $("#dm-details-body").html('<div class="dm-empty">' + T("common/loadFailedAdmin", "Failed to load (admin only).") + '</div>'); });
         }
 
         // row click -> details (ignore clicks on action buttons)
@@ -659,7 +664,7 @@
         // details action buttons
         $("#dm-details-body").on("click", "[data-dact]", function () {
             var $b = $(this), act = $b.data("dact"), id = String($b.data("id")), name = $b.data("name");
-            if (act === "remove") { if (!confirm("Remove container \"" + (name || id) + "\"? This cannot be undone.")) return; closeAllDrawers(); doAction("remove", id); return; }
+            if (act === "remove") { if (!confirm(T("containers/confirmRemoveShort", "Remove container \"{name}\"? This cannot be undone.", { name: name || id }))) return; closeAllDrawers(); doAction("remove", id); return; }
             if (act === "logs" || act === "console" || act === "config") { handleContainerAction(act, id, name, $b); return; }
             handleContainerAction(act, id, name, $b);
         });
@@ -668,63 +673,63 @@
         /* ============ Images ============ */
         var $iBody = $("#dm-i-body");
         function loadImages() {
-            $iBody.html('<tr><td colspan="6" class="dm-empty">Loading…</td></tr>');
+            $iBody.html('<tr><td colspan="6" class="dm-empty">' + T("common/loading", "Loading…") + '</td></tr>');
             track(DM.get("images/list", function (list) {
                 if (list && list.error) { $iBody.html('<tr><td colspan="6" class="dm-empty">' + DM.esc(list.error) + '</td></tr>'); return; }
-                if (!list || list.length === 0) { $iBody.html('<tr><td colspan="6" class="dm-empty">No images found.</td></tr>'); return; }
+                if (!list || list.length === 0) { $iBody.html('<tr><td colspan="6" class="dm-empty">' + T("images/none", "No images found.") + '</td></tr>'); return; }
                 $iBody.html(list.map(function (img) {
                     var idShort = (img.ID || "").replace("sha256:", "").substring(0, 12);
                     return '<tr><td class="dm-mono">' + DM.esc(img.Repository) + '</td><td class="dm-mono">' + DM.esc(img.Tag) + '</td><td>' + DM.esc(img.Size || "") + '</td><td>' + DM.esc(img.CreatedSince || "") + '</td><td class="dm-mono">' + DM.esc(idShort) + '</td>' +
-                        '<td><div class="btnrow"><div class="iconbtn green" title="Run" data-act="runimg" data-ref="' + DM.esc(img.Repository + ":" + img.Tag) + '"><span class="ic ic-start"></span></div><div class="iconbtn danger" title="Remove" data-act="rmi" data-id="' + DM.esc(img.ID) + '" data-name="' + DM.esc(img.Repository + ":" + img.Tag) + '"><span class="ic ic-delete"></span></div></div></td></tr>';
+                        '<td><div class="btnrow"><div class="iconbtn green" title="' + T("act/run", "Run") + '" data-act="runimg" data-ref="' + DM.esc(img.Repository + ":" + img.Tag) + '"><span class="ic ic-start"></span></div><div class="iconbtn danger" title="' + T("act/remove", "Remove") + '" data-act="rmi" data-id="' + DM.esc(img.ID) + '" data-name="' + DM.esc(img.Repository + ":" + img.Tag) + '"><span class="ic ic-delete"></span></div></div></td></tr>';
                 }).join(""));
-            }, function () { $iBody.html('<tr><td colspan="6" class="dm-empty">Failed to load (admin only).</td></tr>'); }));
+            }, function () { $iBody.html('<tr><td colspan="6" class="dm-empty">' + T("common/loadFailedAdmin", "Failed to load (admin only).") + '</td></tr>'); }));
         }
         var pullSocket = null;
         function pullImage(ref) {
-            $("#dm-pull-title").text(ref); $("#dm-log-output-pull").text("Starting pull…\n");
+            $("#dm-pull-title").text(ref); $("#dm-log-output-pull").text(T("images/starting", "Starting pull…") + "\n");
             openDrawer("dm-pull-drawer", function () { if (pullSocket) { try { pullSocket.close(); } catch (e) {} pullSocket = null; } loadImages(); });
             try {
                 pullSocket = new WebSocket(DM.wsURL("images/pull?image=" + encodeURIComponent(ref)));
                 pullSocket.onmessage = function (ev) { var o = document.getElementById("dm-log-output-pull"); o.textContent += ev.data; o.scrollTop = o.scrollHeight; };
-                pullSocket.onclose = function () { document.getElementById("dm-log-output-pull").textContent += "\n[pull finished]"; };
-                pullSocket.onerror = function () { DM.toast("Pull stream error.", false); };
-            } catch (e) { DM.toast("Unable to start pull.", false); }
+                pullSocket.onclose = function () { document.getElementById("dm-log-output-pull").textContent += "\n[" + T("images/finished", "pull finished") + "]"; };
+                pullSocket.onerror = function () { DM.toast(T("images/streamError", "Pull stream error."), false); };
+            } catch (e) { DM.toast(T("images/startFailed", "Unable to start pull."), false); }
         }
         $iBody.on("click", "[data-act='rmi']", function () {
             var $b = $(this), id = String($b.data("id")), name = $b.data("name");
-            if (!confirm("Remove image \"" + (name || id) + "\"? This cannot be undone.")) return;
-            track(DM.post("images/remove", { id: id }, function (r) { if (r && r.error) { DM.toast("Remove failed: " + r.error, false); return; } DM.toast("Image removed.", true); loadImages(); }, function () { DM.toast("Remove request failed.", false); }), $b);
+            if (!confirm(T("images/confirmRemove", "Remove image \"{name}\"? This cannot be undone.", { name: name || id }))) return;
+            track(DM.post("images/remove", { id: id }, function (r) { if (r && r.error) { DM.toast(T("images/removeFailed", "Remove failed: {error}", { error: r.error }), false); return; } DM.toast(T("images/removed", "Image removed."), true); loadImages(); }, function () { DM.toast(T("images/removeRequestFailed", "Remove request failed."), false); }), $b);
         });
         $iBody.on("click", "[data-act='runimg']", function () { openRunDrawer({ image: String($(this).data("ref")) }, ""); });
-        $("#dm-pull-btn").on("click", function () { var ref = $("#dm-pull-ref").val().trim(); if (!ref) { DM.toast("Enter an image reference.", false); return; } pullImage(ref); });
+        $("#dm-pull-btn").on("click", function () { var ref = $("#dm-pull-ref").val().trim(); if (!ref) { DM.toast(T("images/enterRef", "Enter an image reference."), false); return; } pullImage(ref); });
         $("#dm-i-refresh").on("click", loadImages);
 
         /* ============ Registries ============ */
         function loadRegistries() {
-            var $rb = $("#dm-r-body"); $rb.html('<tr><td class="dm-empty">Loading…</td></tr>');
+            var $rb = $("#dm-r-body"); $rb.html('<tr><td class="dm-empty">' + T("common/loading", "Loading…") + '</td></tr>');
             track(DM.get("registry/list", function (list) {
                 if (list && list.error) { $rb.html('<tr><td class="dm-empty">' + DM.esc(list.error) + '</td></tr>'); return; }
-                if (!list || list.length === 0) { $rb.html('<tr><td class="dm-empty">No registries logged in.</td></tr>'); return; }
-                $rb.html(list.map(function (reg) { return '<tr><td class="dm-mono">' + DM.esc(reg) + '</td><td style="text-align:right;"><button class="btn sm" data-logout="' + DM.esc(reg) + '">Logout</button></td></tr>'; }).join(""));
-            }, function () { $rb.html('<tr><td class="dm-empty">Failed to load (admin only).</td></tr>'); }));
+                if (!list || list.length === 0) { $rb.html('<tr><td class="dm-empty">' + T("registry/none", "No registries logged in.") + '</td></tr>'); return; }
+                $rb.html(list.map(function (reg) { return '<tr><td class="dm-mono">' + DM.esc(reg) + '</td><td style="text-align:right;"><button class="btn sm" data-logout="' + DM.esc(reg) + '">' + T("registry/logout", "Logout") + '</button></td></tr>'; }).join(""));
+            }, function () { $rb.html('<tr><td class="dm-empty">' + T("common/loadFailedAdmin", "Failed to load (admin only).") + '</td></tr>'); }));
         }
         $("#dm-login-btn").on("click", function () {
             var $b = $(this), data = { registry: $("#dm-login-registry").val().trim(), username: $("#dm-login-user").val().trim(), password: $("#dm-login-pass").val() };
-            if (!data.username || !data.password) { DM.toast("Username and password required.", false); return; }
-            track(DM.post("registry/login", data, function (r) { if (r && r.error) { DM.toast("Login failed: " + r.error, false); return; } DM.toast("Logged in.", true); $("#dm-login-pass").val(""); loadRegistries(); }, function () { DM.toast("Login request failed.", false); }), $b);
+            if (!data.username || !data.password) { DM.toast(T("registry/credRequired", "Username and password required."), false); return; }
+            track(DM.post("registry/login", data, function (r) { if (r && r.error) { DM.toast(T("registry/loginFailed", "Login failed: {error}", { error: r.error }), false); return; } DM.toast(T("registry/loggedIn", "Logged in."), true); $("#dm-login-pass").val(""); loadRegistries(); }, function () { DM.toast(T("registry/loginRequestFailed", "Login request failed."), false); }), $b);
         });
         $("#dm-r-body").on("click", "[data-logout]", function () {
             var $b = $(this), reg = String($b.data("logout"));
-            track(DM.post("registry/logout", { registry: reg === "https://index.docker.io/v1/" ? "" : reg }, function (r) { if (r && r.error) { DM.toast("Logout failed: " + r.error, false); return; } DM.toast("Logged out.", true); loadRegistries(); }, function () { DM.toast("Logout request failed.", false); }), $b);
+            track(DM.post("registry/logout", { registry: reg === "https://index.docker.io/v1/" ? "" : reg }, function (r) { if (r && r.error) { DM.toast(T("registry/logoutFailed", "Logout failed: {error}", { error: r.error }), false); return; } DM.toast(T("registry/loggedOut", "Logged out."), true); loadRegistries(); }, function () { DM.toast(T("registry/logoutRequestFailed", "Logout request failed."), false); }), $b);
         });
         function doSearch() {
             var term = $("#dm-search-term").val().trim(), $sb = $("#dm-search-body"); if (!term) return;
-            $sb.html('<tr><td colspan="3" class="dm-empty">Searching…</td></tr>');
+            $sb.html('<tr><td colspan="3" class="dm-empty">' + T("registry/searching", "Searching…") + '</td></tr>');
             track(DM.get("registry/search?term=" + encodeURIComponent(term), function (list) {
                 if (list && list.error) { $sb.html('<tr><td colspan="3" class="dm-empty">' + DM.esc(list.error) + '</td></tr>'); return; }
-                if (!list || list.length === 0) { $sb.html('<tr><td colspan="3" class="dm-empty">No results.</td></tr>'); return; }
-                $sb.html(list.map(function (sr) { var off = sr.IsOfficial && sr.IsOfficial.indexOf("OK") >= 0 ? ' <span style="color:var(--ok-text);font-size:10px;">official</span>' : ''; return '<tr><td class="dm-mono">' + DM.esc(sr.Name) + off + '</td><td>' + DM.esc(sr.StarCount || "") + '</td><td><button class="btn sm primary" data-pull="' + DM.esc(sr.Name) + '">Pull</button></td></tr>'; }).join(""));
-            }, function () { $sb.html('<tr><td colspan="3" class="dm-empty">Search failed (admin only).</td></tr>'); }), $("#dm-search-btn"));
+                if (!list || list.length === 0) { $sb.html('<tr><td colspan="3" class="dm-empty">' + T("registry/noResults", "No results.") + '</td></tr>'); return; }
+                $sb.html(list.map(function (sr) { var off = sr.IsOfficial && sr.IsOfficial.indexOf("OK") >= 0 ? ' <span style="color:var(--ok-text);font-size:10px;">' + T("registry/official", "official") + '</span>' : ''; return '<tr><td class="dm-mono">' + DM.esc(sr.Name) + off + '</td><td>' + DM.esc(sr.StarCount || "") + '</td><td><button class="btn sm primary" data-pull="' + DM.esc(sr.Name) + '">' + T("images/pull", "Pull") + '</button></td></tr>'; }).join(""));
+            }, function () { $sb.html('<tr><td colspan="3" class="dm-empty">' + T("registry/searchFailed", "Search failed (admin only).") + '</td></tr>'); }), $("#dm-search-btn"));
         }
         $("#dm-search-btn").on("click", doSearch);
         $("#dm-search-term").on("keydown", function (e) { if (e.key === "Enter") doSearch(); });
@@ -742,8 +747,8 @@
         }
         function openRunDrawer(prefill, oldId) {
             recreateOldId = oldId || ""; var recreate = !!recreateOldId;
-            $("#dm-run-title").text(recreate ? "Recreate Container" : "Run Image"); $("#dm-run-warn").toggle(recreate);
-            $("#dm-run-gotext").text(recreate ? "Recreate" : "Run"); $("#dm-run-createonly").toggle(!recreate);
+            $("#dm-run-title").text(recreate ? T("run/recreateTitle", "Recreate Container") : T("run/title", "Run Image")); $("#dm-run-warn").toggle(recreate);
+            $("#dm-run-gotext").text(recreate ? T("run/recreate", "Recreate") : T("run/run", "Run")); $("#dm-run-createonly").toggle(!recreate);
             fillRunForm(prefill || {}); openDrawer("dm-run-drawer");
         }
         function inspectToConfig(d) {
@@ -759,11 +764,11 @@
             return cfg;
         }
         function runOrRecreate(createOnly, $btn) {
-            var cfg = collectRunConfig(); if (!cfg.image) { DM.toast("Image is required.", false); return; }
+            var cfg = collectRunConfig(); if (!cfg.image) { DM.toast(T("run/imageRequired", "Image is required."), false); return; }
             var url = recreateOldId ? (DM.api + "containers/recreate?oldId=" + encodeURIComponent(recreateOldId)) : (DM.api + "containers/" + (createOnly ? "create" : "run"));
             track($.ajax({ url: url, method: "POST", data: JSON.stringify(cfg), contentType: "application/json" })
-                .done(function (r) { if (r && r.error) { DM.toast("Failed: " + r.error, false); return; } DM.toast(recreateOldId ? "Container recreated." : (createOnly ? "Container created." : "Container started."), true); closeAllDrawers(); showSection("containers"); loadContainers(); })
-                .fail(function () { DM.toast("Request failed.", false); }), $btn);
+                .done(function (r) { if (r && r.error) { DM.toast(T("common/failed", "Failed: {error}", { error: r.error }), false); return; } DM.toast(recreateOldId ? T("run/recreated", "Container recreated.") : (createOnly ? T("run/created", "Container created.") : T("run/started", "Container started.")), true); closeAllDrawers(); showSection("containers"); loadContainers(); })
+                .fail(function () { DM.toast(T("common/requestFailed", "Request failed."), false); }), $btn);
         }
         $("#dm-run-go").on("click", function () { runOrRecreate(false, $(this)); });
         $("#dm-run-createonly").on("click", function () { runOrRecreate(true, $(this)); });
@@ -772,13 +777,13 @@
         var cfgRef = "";
         function openConfig(id, name) { cfgRef = id; $("#dm-cfg-title").text(name || id); $("#dm-cfg-name").val(name || ""); $("#dm-cfg-memory").val(""); $("#dm-cfg-cpus").val(""); $("#dm-cfg-restart").val(""); openDrawer("dm-cfg-drawer"); }
         $("#dm-cfg-rename").on("click", function () {
-            var $b = $(this), nn = $("#dm-cfg-name").val().trim(); if (!nn) { DM.toast("Enter a new name.", false); return; }
-            track(DM.post("containers/rename", { id: cfgRef, name: nn }, function (r) { if (r && r.error) { DM.toast("Rename failed: " + r.error, false); return; } DM.toast("Renamed.", true); closeAllDrawers(); loadContainers(); }, function () { DM.toast("Rename request failed.", false); }), $b);
+            var $b = $(this), nn = $("#dm-cfg-name").val().trim(); if (!nn) { DM.toast(T("cfg/enterName", "Enter a new name."), false); return; }
+            track(DM.post("containers/rename", { id: cfgRef, name: nn }, function (r) { if (r && r.error) { DM.toast(T("cfg/renameFailed", "Rename failed: {error}", { error: r.error }), false); return; } DM.toast(T("cfg/renamed", "Renamed."), true); closeAllDrawers(); loadContainers(); }, function () { DM.toast(T("cfg/renameRequestFailed", "Rename request failed."), false); }), $b);
         });
         $("#dm-cfg-apply").on("click", function () {
             var $b = $(this), data = { id: cfgRef, restartPolicy: $("#dm-cfg-restart").val(), memory: $("#dm-cfg-memory").val().trim(), cpus: $("#dm-cfg-cpus").val().trim() };
-            if (!data.restartPolicy && !data.memory && !data.cpus) { DM.toast("Nothing to update.", false); return; }
-            track(DM.post("containers/update", data, function (r) { if (r && r.error) { DM.toast("Update failed: " + r.error, false); return; } DM.toast("Updated.", true); closeAllDrawers(); loadContainers(); }, function () { DM.toast("Update request failed.", false); }), $b);
+            if (!data.restartPolicy && !data.memory && !data.cpus) { DM.toast(T("cfg/nothing", "Nothing to update."), false); return; }
+            track(DM.post("containers/update", data, function (r) { if (r && r.error) { DM.toast(T("cfg/updateFailed", "Update failed: {error}", { error: r.error }), false); return; } DM.toast(T("cfg/updated", "Updated."), true); closeAllDrawers(); loadContainers(); }, function () { DM.toast(T("cfg/updateRequestFailed", "Update request failed."), false); }), $b);
         });
         $("#dm-cfg-recreate").on("click", function () {
             var ref = cfgRef; closeAllDrawers();
@@ -789,27 +794,27 @@
         var $stackBody = $("#dm-stack-body"); var aceEditor = null, composeUnavailable = false;
         function ensureEditor() { if (aceEditor) return aceEditor; aceEditor = ace.edit("dm-stack-editor"); aceEditor.session.setMode("ace/mode/yaml"); aceEditor.setOptions({ fontSize: "13px", showPrintMargin: false, tabSize: 2, useSoftTabs: true }); aceEditor.setTheme(document.body.classList.contains("dark") ? "ace/theme/monokai" : "ace/theme/chrome"); return aceEditor; }
         function loadStacks() {
-            $stackBody.html('<tr><td colspan="4" class="dm-empty">Loading…</td></tr>');
+            $stackBody.html('<tr><td colspan="4" class="dm-empty">' + T("common/loading", "Loading…") + '</td></tr>');
             track(DM.get("compose/list", function (list) {
                 if (list && list.error) { $stackBody.html('<tr><td colspan="4" class="dm-empty">' + DM.esc(list.error) + '</td></tr>'); return; }
-                if (!list || list.length === 0) { $stackBody.html('<tr><td colspan="4" class="dm-empty">No stacks yet. Click "New Stack" to create one.</td></tr>'); return; }
+                if (!list || list.length === 0) { $stackBody.html('<tr><td colspan="4" class="dm-empty">' + T("compose/none", "No stacks yet. Click \"New Stack\" to create one.") + '</td></tr>'); return; }
                 $stackBody.html(list.map(function (s) {
-                    var sb = s.running ? '<span class="pill green"><span class="dot"></span>running</span>' : '<span class="pill gray"><span class="dot"></span>stopped</span>';
+                    var sb = s.running ? '<span class="pill green"><span class="dot"></span>' + T("state/running", "running") + '</span>' : '<span class="pill gray"><span class="dot"></span>' + T("compose/stopped", "stopped") + '</span>';
                     var n = DM.esc(s.name);
-                    var auto = (s.disabled ? '<span style="color:var(--dim);">disabled</span>' : '<span style="color:var(--ok-text);">enabled</span>') + ' <button class="btn sm" data-sact="toggle" data-name="' + n + '" data-disabled="' + (s.disabled ? "1" : "0") + '">' + (s.disabled ? "Enable" : "Disable") + '</button>';
+                    var auto = (s.disabled ? '<span style="color:var(--dim);">' + T("compose/disabled", "disabled") + '</span>' : '<span style="color:var(--ok-text);">' + T("compose/enabled", "enabled") + '</span>') + ' <button class="btn sm" data-sact="toggle" data-name="' + n + '" data-disabled="' + (s.disabled ? "1" : "0") + '">' + (s.disabled ? T("compose/enable", "Enable") : T("compose/disable", "Disable")) + '</button>';
                     return '<tr><td class="dm-mono">' + n + '</td><td>' + sb + '</td><td>' + auto + '</td>' +
                         '<td><div class="btnrow">' +
-                        '<div class="iconbtn" title="Edit" data-sact="edit" data-name="' + n + '"><span class="ic ic-edit"></span></div>' +
-                        '<div class="iconbtn green" title="Deploy" data-sact="deploy" data-name="' + n + '"><span class="ic ic-start"></span></div>' +
-                        '<div class="iconbtn" title="Stop" data-sact="down" data-name="' + n + '"><span class="ic ic-stop"></span></div>' +
-                        '<div class="iconbtn" title="Logs" data-sact="logs" data-name="' + n + '"><span class="ic ic-logs"></span></div>' +
-                        '<div class="iconbtn danger" title="Delete" data-sact="delete" data-name="' + n + '"><span class="ic ic-delete"></span></div>' +
+                        '<div class="iconbtn" title="' + T("act/edit", "Edit") + '" data-sact="edit" data-name="' + n + '"><span class="ic ic-edit"></span></div>' +
+                        '<div class="iconbtn green" title="' + T("act/deploy", "Deploy") + '" data-sact="deploy" data-name="' + n + '"><span class="ic ic-start"></span></div>' +
+                        '<div class="iconbtn" title="' + T("act/stop", "Stop") + '" data-sact="down" data-name="' + n + '"><span class="ic ic-stop"></span></div>' +
+                        '<div class="iconbtn" title="' + T("act/logs", "Logs") + '" data-sact="logs" data-name="' + n + '"><span class="ic ic-logs"></span></div>' +
+                        '<div class="iconbtn danger" title="' + T("act/delete", "Delete") + '" data-sact="delete" data-name="' + n + '"><span class="ic ic-delete"></span></div>' +
                         '</div></td></tr>';
                 }).join(""));
-            }, function () { $stackBody.html('<tr><td colspan="4" class="dm-empty">Failed to load (admin only).</td></tr>'); }));
+            }, function () { $stackBody.html('<tr><td colspan="4" class="dm-empty">' + T("common/loadFailedAdmin", "Failed to load (admin only).") + '</td></tr>'); }));
         }
         function openStackEditor(name, content) {
-            $("#dm-stack-modal-title").text(name ? ("Edit Stack: " + name) : "New Stack"); $("#dm-stack-name").val(name || "").prop("disabled", !!name);
+            $("#dm-stack-modal-title").text(name ? T("compose/editTitle", "Edit Stack: {name}", { name: name }) : T("compose/newStack", "New Stack")); $("#dm-stack-name").val(name || "").prop("disabled", !!name);
             $("#dm-stack-validate-msg").hide(); $("#dm-stack-status").text(""); openDrawer("dm-stack-drawer");
             setTimeout(function () { var ed = ensureEditor(); ed.setValue(content || "services:\n  app:\n    image: nginx:alpine\n    ports:\n      - \"8080:80\"\n", -1); ed.resize(); }, 80);
         }
@@ -817,23 +822,23 @@
         function showValidateMsg(ok, text) { $("#dm-stack-validate-msg").removeClass("ok err").addClass(ok ? "ok" : "err").html("<div>" + DM.esc(text) + "</div>").show(); }
         function setStackStatus(t) { $("#dm-stack-status").text(t || ""); }
         function saveStack(thenDeploy, $btn) {
-            var payload = currentStackPayload(); if (!payload.name) { DM.toast("Stack name is required.", false); return; }
-            setStackStatus("Validating & saving…");
+            var payload = currentStackPayload(); if (!payload.name) { DM.toast(T("compose/nameRequired", "Stack name is required."), false); return; }
+            setStackStatus(T("compose/saving", "Validating & saving…"));
             track($.ajax({ url: DM.api + "compose/save", method: "POST", data: JSON.stringify(payload), contentType: "application/json" })
                 .done(function (r) {
                     if (r && r.error) { setStackStatus(""); showValidateMsg(false, r.error); return; }
                     if (thenDeploy) {
-                        setStackStatus("Deploying… pulling images may take a while.");
-                        track(DM.post("compose/deploy", { name: payload.name }, function (rr) { setStackStatus(""); if (rr && rr.error) { DM.toast("Deploy failed: " + rr.error, false); return; } DM.toast("Stack deployed.", true); closeAllDrawers(); loadStacks(); }, function () { setStackStatus(""); DM.toast("Deploy request failed.", false); }), $btn);
-                    } else { setStackStatus(""); DM.toast("Stack saved.", true); closeAllDrawers(); loadStacks(); }
+                        setStackStatus(T("compose/deploying", "Deploying… pulling images may take a while."));
+                        track(DM.post("compose/deploy", { name: payload.name }, function (rr) { setStackStatus(""); if (rr && rr.error) { DM.toast(T("compose/deployFailed", "Deploy failed: {error}", { error: rr.error }), false); return; } DM.toast(T("compose/deployed", "Stack deployed."), true); closeAllDrawers(); loadStacks(); }, function () { setStackStatus(""); DM.toast(T("compose/deployRequestFailed", "Deploy request failed."), false); }), $btn);
+                    } else { setStackStatus(""); DM.toast(T("compose/saved", "Stack saved."), true); closeAllDrawers(); loadStacks(); }
                 })
-                .fail(function () { setStackStatus(""); DM.toast("Save request failed.", false); }), thenDeploy ? null : $btn);
+                .fail(function () { setStackStatus(""); DM.toast(T("compose/saveRequestFailed", "Save request failed."), false); }), thenDeploy ? null : $btn);
         }
         function validateStack() {
-            var payload = currentStackPayload(); if (!payload.name) { DM.toast("Stack name is required to validate.", false); return; }
+            var payload = currentStackPayload(); if (!payload.name) { DM.toast(T("compose/nameRequiredValidate", "Stack name is required to validate."), false); return; }
             track($.ajax({ url: DM.api + "compose/save", method: "POST", data: JSON.stringify(payload), contentType: "application/json" })
-                .done(function (r) { if (r && r.error) showValidateMsg(false, r.error); else { showValidateMsg(true, "Compose file is valid and saved."); loadStacks(); } })
-                .fail(function () { DM.toast("Validate request failed.", false); }), $("#dm-stack-validate"));
+                .done(function (r) { if (r && r.error) showValidateMsg(false, r.error); else { showValidateMsg(true, T("compose/valid", "Compose file is valid and saved.")); loadStacks(); } })
+                .fail(function () { DM.toast(T("compose/validateRequestFailed", "Validate request failed."), false); }), $("#dm-stack-validate"));
         }
         $("#dm-stack-new").on("click", function () { if (composeUnavailable) return; openStackEditor("", ""); });
         $("#dm-stack-refresh").on("click", loadStacks);
@@ -842,13 +847,13 @@
         $("#dm-stack-savedeploy").on("click", function () { saveStack(true, $(this)); });
         $stackBody.on("click", "[data-sact]", function () {
             var $b = $(this), act = $b.data("sact"), name = String($b.data("name"));
-            if (act === "edit") { track(DM.get("compose/get?name=" + encodeURIComponent(name), function (r) { if (r && r.error) { DM.toast(r.error, false); return; } openStackEditor(name, r.content); }, function () { DM.toast("Failed to load stack.", false); }), $b); return; }
-            if (act === "logs") { streamLog("stack: " + name, "compose/logs?name=" + encodeURIComponent(name)); return; }
-            if (act === "toggle") { var disabled = String($b.data("disabled")) === "1"; track(DM.post("compose/disable", { name: name, disabled: disabled ? "false" : "true" }, function (r) { if (r && r.error) { DM.toast(r.error, false); return; } loadStacks(); }, function () { DM.toast("Toggle failed.", false); }), $b); return; }
-            if (act === "delete") { if (!confirm("Delete stack \"" + name + "\"? This stops its containers and deletes the stack files. This cannot be undone.")) return; track(DM.post("compose/delete", { name: name }, function (r) { if (r && r.error) { DM.toast("Delete failed: " + r.error, false); return; } DM.toast("Stack deleted.", true); loadStacks(); }, function () { DM.toast("Delete request failed.", false); }), $b); return; }
-            if (act === "down" && !confirm("Stop stack \"" + name + "\"? This removes its running containers (the stack files are kept).")) return;
-            if (act === "deploy") DM.toast("Deploying stack… this can take a while.", null);
-            track(DM.post("compose/" + act, { name: name }, function (r) { if (r && r.error) { DM.toast(act + " failed: " + r.error, false); return; } DM.toast("Stack " + act + " ok.", true); loadStacks(); }, function () { DM.toast(act + " request failed.", false); }), $b);
+            if (act === "edit") { track(DM.get("compose/get?name=" + encodeURIComponent(name), function (r) { if (r && r.error) { DM.toast(r.error, false); return; } openStackEditor(name, r.content); }, function () { DM.toast(T("compose/loadStackFailed", "Failed to load stack."), false); }), $b); return; }
+            if (act === "logs") { streamLog(T("compose/logTitle", "stack: {name}", { name: name }), "compose/logs?name=" + encodeURIComponent(name)); return; }
+            if (act === "toggle") { var disabled = String($b.data("disabled")) === "1"; track(DM.post("compose/disable", { name: name, disabled: disabled ? "false" : "true" }, function (r) { if (r && r.error) { DM.toast(r.error, false); return; } loadStacks(); }, function () { DM.toast(T("compose/toggleFailed", "Toggle failed."), false); }), $b); return; }
+            if (act === "delete") { if (!confirm(T("compose/confirmDelete", "Delete stack \"{name}\"? This stops its containers and deletes the stack files. This cannot be undone.", { name: name }))) return; track(DM.post("compose/delete", { name: name }, function (r) { if (r && r.error) { DM.toast(T("compose/deleteFailed", "Delete failed: {error}", { error: r.error }), false); return; } DM.toast(T("compose/deleted", "Stack deleted."), true); loadStacks(); }, function () { DM.toast(T("compose/deleteRequestFailed", "Delete request failed."), false); }), $b); return; }
+            if (act === "down" && !confirm(T("compose/confirmDown", "Stop stack \"{name}\"? This removes its running containers (the stack files are kept).", { name: name }))) return;
+            if (act === "deploy") DM.toast(T("compose/deployingStack", "Deploying stack… this can take a while."), null);
+            track(DM.post("compose/" + act, { name: name }, function (r) { if (r && r.error) { DM.toast(T("toast/actFailed", "{action} failed: {error}", { action: verb(act), error: r.error }), false); return; } DM.toast(T("toast/stackOk", "Stack {action} ok.", { action: verb(act) }), true); loadStacks(); }, function () { DM.toast(T("toast/actRequestFailed", "{action} request failed.", { action: verb(act) }), false); }), $b);
         });
         function loadComposeTab() {
             DM.get("engine/status", function (st) { composeUnavailable = !(st && st.composeAvailable); $("#dm-compose-unavail").toggle(composeUnavailable); $("#dm-stack-card").toggle(!composeUnavailable); $("#dm-stack-new").prop("disabled", composeUnavailable); if (!composeUnavailable) loadStacks(); }, function () { loadStacks(); });
@@ -870,8 +875,8 @@
             consoleSocket.binaryType = "arraybuffer";
             consoleSocket.onopen = function () { sendResize(); };
             consoleSocket.onmessage = function (ev) { if (typeof ev.data === "string") term.write(ev.data); else term.write(new Uint8Array(ev.data)); };
-            consoleSocket.onclose = function () { if (term) term.write("\r\n[session closed]\r\n"); };
-            consoleSocket.onerror = function () { DM.toast("Console connection error.", false); };
+            consoleSocket.onclose = function () { if (term) term.write("\r\n[" + T("console/closed", "session closed") + "]\r\n"); };
+            consoleSocket.onerror = function () { DM.toast(T("console/connError", "Console connection error."), false); };
             term.onData(function (d) { if (consoleSocket && consoleSocket.readyState === 1) consoleSocket.send(new TextEncoder().encode(d)); });
             term.onResize(function () { sendResize(); });
         }
@@ -885,7 +890,7 @@
             var shell = $("#dm-console-shell").val() || "sh";
             var url = "DockerManager/console.html?id=" + encodeURIComponent(consoleRef) + "&shell=" + encodeURIComponent(shell) + "&name=" + encodeURIComponent(consoleName);
             if (typeof ao_module_newfw === "function") {
-                ao_module_newfw({ url: url, width: 900, height: 600, appicon: "DockerManager/img/icon.svg", title: "Console: " + consoleName });
+                ao_module_newfw({ url: url, width: 900, height: 600, appicon: "DockerManager/img/icon.svg", title: T("drawer/console", "Console:") + " " + consoleName });
             } else { window.open("../" + url, "_blank"); }
             closeDrawer();
         });
@@ -908,7 +913,7 @@
             DM.get("host/stats", function (s) {
                 if (!s || s.error) return;
                 $("#st-cpu").text((s.cpuPercent || 0).toFixed(1) + "%");
-                $("#st-cpu-sub").text((s.cpuCores || 0) + " cores");
+                $("#st-cpu-sub").text(T("stats/cores", "{n} cores", { n: s.cpuCores || 0 }));
                 pushHist("cpu", s.cpuPercent || 0);
                 $("#st-ram").text((s.ramPercent || 0).toFixed(1) + "%");
                 $("#st-ram-sub").text(fmtGB(s.ramUsed) + " / " + fmtGB(s.ramTotal));
@@ -917,12 +922,12 @@
                     $("#st-storage").text((s.storagePercent || 0).toFixed(1) + "%");
                     $("#st-storage-sub").text(fmtGB(s.storageUsed) + " / " + fmtGB(s.storageTotal));
                     pushHist("storage", s.storagePercent || 0);
-                } else { $("#st-storage").text("n/a"); $("#st-storage-sub").text("unavailable"); }
+                } else { $("#st-storage").text(T("stats/na", "n/a")); $("#st-storage-sub").text(T("stats/unavailable", "unavailable")); }
                 if (s.loadAvailable) {
                     $("#st-load").html((s.load1 || 0).toFixed(2) + ' <small>1m</small>');
                     $("#st-load-sub").text("5m " + (s.load5 || 0).toFixed(2) + "   15m " + (s.load15 || 0).toFixed(2));
                     pushHist("load", s.load1 || 0);
-                } else { $("#st-load").text("n/a"); $("#st-load-sub").text("unavailable"); }
+                } else { $("#st-load").text(T("stats/na", "n/a")); $("#st-load-sub").text(T("stats/unavailable", "unavailable")); }
                 renderSpark("sp-cpu", statHist.cpu, 100);
                 renderSpark("sp-ram", statHist.ram, 100);
                 renderSpark("sp-storage", statHist.storage, 100);
@@ -933,7 +938,7 @@
         setInterval(loadHostStats, 4000);
 
         loadContainers();
-    })();
+    }
     </script>
 </body>
 

+ 84 - 0
src/web/DockerManager/locale/console.json

@@ -0,0 +1,84 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "strings": {
+                "console/shell": "Shell:",
+                "console/reconnect": "重新連線",
+                "console/closed": "工作階段已結束",
+                "console/live": "即時容器 Shell",
+                "console/title": "主控台:{name}",
+                "console/missingId": "缺少容器 ID。"
+            },
+            "titles": {},
+            "placeholder": {}
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "strings": {
+                "console/shell": "Shell:",
+                "console/reconnect": "重新連線",
+                "console/closed": "工作階段已結束",
+                "console/live": "即時容器 Shell",
+                "console/title": "主控台:{name}",
+                "console/missingId": "缺少容器 ID。"
+            },
+            "titles": {},
+            "placeholder": {}
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "strings": {
+                "console/shell": "Shell:",
+                "console/reconnect": "重新连接",
+                "console/closed": "会话已关闭",
+                "console/live": "实时容器 Shell",
+                "console/title": "控制台:{name}",
+                "console/missingId": "缺少容器 ID。"
+            },
+            "titles": {},
+            "placeholder": {}
+        },
+        "en-us": {
+            "name": "English (US)",
+            "strings": {
+                "console/shell": "Shell:",
+                "console/reconnect": "Reconnect",
+                "console/closed": "session closed",
+                "console/live": "Live container shell",
+                "console/title": "Console: {name}",
+                "console/missingId": "Missing container id."
+            },
+            "titles": {},
+            "placeholder": {}
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "strings": {
+                "console/shell": "シェル:",
+                "console/reconnect": "再接続",
+                "console/closed": "セッションが終了しました",
+                "console/live": "ライブコンテナシェル",
+                "console/title": "コンソール: {name}",
+                "console/missingId": "コンテナ ID がありません。"
+            },
+            "titles": {},
+            "placeholder": {}
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "strings": {
+                "console/shell": "셸:",
+                "console/reconnect": "다시 연결",
+                "console/closed": "세션이 종료됨",
+                "console/live": "라이브 컨테이너 셸",
+                "console/title": "콘솔: {name}",
+                "console/missingId": "컨테이너 ID가 없습니다."
+            },
+            "titles": {},
+            "placeholder": {}
+        }
+    }
+}

+ 1326 - 0
src/web/DockerManager/locale/dockermanager.json

@@ -0,0 +1,1326 @@
+{
+    "author": "tobychui",
+    "version": "1.0",
+    "keys": {
+        "zh-tw": {
+            "name": "繁體中文(台灣)",
+            "fwtitle": "Docker 管理員",
+            "strings": {
+                "common/cancel": "取消",
+                "common/close": "關閉",
+                "common/save": "儲存",
+                "common/refresh": "重新整理",
+                "common/failed": "失敗:{error}",
+                "common/requestFailed": "請求失敗。",
+                "common/loadFailedAdmin": "載入失敗(僅限管理員)。",
+                "app/title": "Docker 管理員",
+                "common/loading": "載入中…",
+                "nav/containers": "容器",
+                "nav/images": "映像檔",
+                "nav/compose": "Compose 堆疊",
+                "nav/registries": "映像檔倉庫",
+                "nav/adminNote": "僅限管理員。Docker 存取權限等同於此主機的 root 權限。",
+                "stats/cpu": "CPU",
+                "stats/ram": "記憶體",
+                "stats/storage": "儲存空間",
+                "stats/load": "負載",
+                "stats/cores": "{n} 核心",
+                "stats/na": "不適用",
+                "stats/unavailable": "無法取得",
+                "table/status": "狀態",
+                "table/container": "容器",
+                "table/actions": "操作",
+                "table/repository": "倉庫",
+                "table/tag": "標籤",
+                "table/size": "大小",
+                "table/created": "建立時間",
+                "table/imageId": "映像檔 ID",
+                "table/stack": "堆疊",
+                "table/state": "狀態",
+                "table/autostart": "自動啟動",
+                "table/name": "名稱",
+                "table/stars": "星數",
+                "state/running": "執行中",
+                "state/exited": "已結束",
+                "state/paused": "已暫停",
+                "state/restarting": "重新啟動中",
+                "state/created": "已建立",
+                "state/removing": "移除中",
+                "state/dead": "已失效",
+                "act/start": "啟動",
+                "act/stop": "停止",
+                "act/restart": "重新啟動",
+                "act/console": "主控台",
+                "act/logs": "日誌",
+                "act/publish": "發佈為應用程式",
+                "act/config": "設定",
+                "act/remove": "移除",
+                "act/run": "執行",
+                "act/edit": "編輯",
+                "act/deploy": "部署",
+                "act/delete": "刪除",
+                "verb/start": "啟動",
+                "verb/stop": "停止",
+                "verb/restart": "重新啟動",
+                "verb/remove": "移除",
+                "verb/deploy": "部署",
+                "verb/down": "停止",
+                "toast/actFailed": "{action}失敗:{error}",
+                "toast/actRequestFailed": "{action}請求失敗。",
+                "toast/containerOk": "容器{action}成功。",
+                "toast/stackOk": "堆疊{action}成功。",
+                "containers/rowHint": "點選任一列以查看完整的容器詳細資料。",
+                "containers/none": "找不到任何容器。",
+                "containers/loadFailed": "載入失敗(需要管理員權限)。",
+                "containers/confirmRemove": "要移除容器「{name}」嗎?這會強制停止並刪除該容器,且無法復原。",
+                "containers/confirmRemoveShort": "要移除容器「{name}」嗎?此操作無法復原。",
+                "publish/appTitle": "容器應用程式",
+                "logs/streamError": "日誌串流錯誤。",
+                "logs/openFailed": "無法開啟日誌串流。",
+                "details/noData": "沒有資料。",
+                "details/state": "狀態",
+                "details/image": "映像檔",
+                "details/containerId": "容器 ID",
+                "details/created": "建立時間",
+                "details/restartPolicy": "重新啟動原則",
+                "details/started": "啟動時間",
+                "details/command": "指令",
+                "details/ports": "連接埠",
+                "details/noPorts": "沒有公開的連接埠。",
+                "details/containerPort": "容器連接埠",
+                "details/protocol": "通訊協定",
+                "details/publishedTo": "發佈至(主機)",
+                "details/notPublished": "未發佈",
+                "details/mounts": "掛載",
+                "details/noMounts": "沒有掛載。",
+                "details/source": "來源",
+                "details/destination": "目的地",
+                "details/mode": "模式",
+                "details/networks": "網路",
+                "details/network": "網路",
+                "details/ip": "IP 位址",
+                "details/gateway": "閘道",
+                "details/environment": "環境變數",
+                "images/pull": "下載",
+                "images/none": "找不到任何映像檔。",
+                "images/starting": "開始下載…",
+                "images/finished": "下載完成",
+                "images/streamError": "下載串流錯誤。",
+                "images/startFailed": "無法開始下載。",
+                "images/confirmRemove": "要移除映像檔「{name}」嗎?此操作無法復原。",
+                "images/removeFailed": "移除失敗:{error}",
+                "images/removed": "已移除映像檔。",
+                "images/removeRequestFailed": "移除請求失敗。",
+                "images/enterRef": "請輸入映像檔名稱。",
+                "compose/newStack": "新增堆疊",
+                "compose/unavailable": "此主機未偵測到 Docker Compose v2 外掛程式。請安裝 <code>docker-compose-plugin</code> 以管理堆疊。",
+                "compose/note": "堆疊存放於 <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>。已啟用自動啟動的堆疊會在伺服器開機時啟動。",
+                "compose/none": "尚未有堆疊。按「新增堆疊」建立一個。",
+                "compose/stopped": "已停止",
+                "compose/disabled": "已停用",
+                "compose/enabled": "已啟用",
+                "compose/enable": "啟用",
+                "compose/disable": "停用",
+                "compose/editTitle": "編輯堆疊:{name}",
+                "compose/nameLabel": "堆疊名稱(英文字母、數字、連字號、底線)",
+                "compose/validate": "驗證",
+                "compose/saveDeploy": "儲存並部署",
+                "compose/nameRequired": "必須填寫堆疊名稱。",
+                "compose/nameRequiredValidate": "驗證前必須填寫堆疊名稱。",
+                "compose/saving": "驗證並儲存中…",
+                "compose/deploying": "部署中…下載映像檔可能需要一段時間。",
+                "compose/deployingStack": "正在部署堆疊…可能需要一段時間。",
+                "compose/deployFailed": "部署失敗:{error}",
+                "compose/deployed": "堆疊已部署。",
+                "compose/deployRequestFailed": "部署請求失敗。",
+                "compose/saved": "堆疊已儲存。",
+                "compose/saveRequestFailed": "儲存請求失敗。",
+                "compose/valid": "Compose 檔案有效並已儲存。",
+                "compose/validateRequestFailed": "驗證請求失敗。",
+                "compose/loadStackFailed": "無法載入堆疊。",
+                "compose/logTitle": "堆疊:{name}",
+                "compose/toggleFailed": "切換失敗。",
+                "compose/confirmDelete": "要刪除堆疊「{name}」嗎?這會停止其容器並刪除堆疊檔案,且無法復原。",
+                "compose/deleteFailed": "刪除失敗:{error}",
+                "compose/deleted": "已刪除堆疊。",
+                "compose/deleteRequestFailed": "刪除請求失敗。",
+                "compose/confirmDown": "要停止堆疊「{name}」嗎?這會移除其執行中的容器(堆疊檔案會保留)。",
+                "registry/loginTitle": "登入",
+                "registry/registry": "倉庫(留空 = Docker Hub)",
+                "registry/username": "使用者名稱",
+                "registry/password": "密碼 / 權杖",
+                "registry/login": "登入",
+                "registry/credNote": "憑證由 Docker CLI 儲存,ArozOS 不會保存。密碼只會傳送一次,且不會被記錄。",
+                "registry/configured": "已設定的倉庫",
+                "registry/searchTitle": "搜尋 Docker Hub",
+                "registry/search": "搜尋",
+                "registry/searchHint": "輸入關鍵字後搜尋。",
+                "registry/none": "尚未登入任何倉庫。",
+                "registry/logout": "登出",
+                "registry/credRequired": "需要使用者名稱和密碼。",
+                "registry/loginFailed": "登入失敗:{error}",
+                "registry/loggedIn": "已登入。",
+                "registry/loginRequestFailed": "登入請求失敗。",
+                "registry/logoutFailed": "登出失敗:{error}",
+                "registry/loggedOut": "已登出。",
+                "registry/logoutRequestFailed": "登出請求失敗。",
+                "registry/searching": "搜尋中…",
+                "registry/noResults": "沒有結果。",
+                "registry/official": "官方",
+                "registry/searchFailed": "搜尋失敗(僅限管理員)。",
+                "drawer/container": "容器:",
+                "drawer/logs": "日誌:",
+                "drawer/pulling": "下載中:",
+                "drawer/configure": "設定:",
+                "drawer/console": "主控台:",
+                "run/title": "執行映像檔",
+                "run/recreateTitle": "重建容器",
+                "run/recreateWarn": "<b>重建</b>會刪除現有的容器,並以這些設定啟動新的容器。此操作無法復原。",
+                "run/image": "映像檔",
+                "run/name": "容器名稱(選填)",
+                "run/ports": "連接埠對應(每行一個)",
+                "run/volumes": "磁碟區(src:dst[:mode])",
+                "run/env": "環境變數(每行一個 KEY=value)",
+                "run/restart": "重新啟動原則",
+                "run/network": "網路(選填)",
+                "run/command": "覆寫指令(選填)",
+                "run/createOnly": "建立(不啟動)",
+                "run/run": "執行",
+                "run/recreate": "重建",
+                "run/imageRequired": "必須填寫映像檔。",
+                "run/recreated": "已重建容器。",
+                "run/created": "已建立容器。",
+                "run/started": "容器已啟動。",
+                "cfg/rename": "重新命名",
+                "cfg/liveUpdate": "即時更新",
+                "cfg/liveUpdateNote": "(無需重建即可套用)",
+                "cfg/unchanged": "(不變更)",
+                "cfg/memory": "記憶體上限",
+                "cfg/cpus": "CPU 數量",
+                "cfg/recreateNote": "變更連接埠、磁碟區或環境變數需要<b>重建</b>容器。",
+                "cfg/recreateLink": "以新設定重建…",
+                "cfg/apply": "套用即時更新",
+                "cfg/enterName": "請輸入新名稱。",
+                "cfg/renameFailed": "重新命名失敗:{error}",
+                "cfg/renamed": "已重新命名。",
+                "cfg/renameRequestFailed": "重新命名請求失敗。",
+                "cfg/nothing": "沒有需要更新的項目。",
+                "cfg/updateFailed": "更新失敗:{error}",
+                "cfg/updated": "已更新。",
+                "cfg/updateRequestFailed": "更新請求失敗。",
+                "console/warn": "您已連線至<b>此容器內的即時 Shell</b>。指令會以容器的權限執行,並可能影響主機。",
+                "console/shell": "Shell:",
+                "console/reconnect": "重新連線",
+                "console/closed": "工作階段已結束",
+                "console/connError": "主控台連線錯誤。"
+            },
+            "titles": {
+                "Open in window": "在視窗中開啟"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine 或 registry/repo:tag",
+                "e.g. nginx": "例如 nginx"
+            }
+        },
+        "zh-hk": {
+            "name": "繁體中文(香港)",
+            "fwtitle": "Docker 管理員",
+            "strings": {
+                "common/cancel": "取消",
+                "common/close": "關閉",
+                "common/save": "儲存",
+                "common/refresh": "重新整理",
+                "common/failed": "失敗:{error}",
+                "common/requestFailed": "請求失敗。",
+                "common/loadFailedAdmin": "載入失敗(僅限管理員)。",
+                "app/title": "Docker 管理員",
+                "common/loading": "載入中…",
+                "nav/containers": "容器",
+                "nav/images": "映像檔",
+                "nav/compose": "Compose 堆疊",
+                "nav/registries": "映像檔倉庫",
+                "nav/adminNote": "僅限管理員。Docker 存取權限等同於此主機的 root 權限。",
+                "stats/cpu": "CPU",
+                "stats/ram": "記憶體",
+                "stats/storage": "儲存空間",
+                "stats/load": "負載",
+                "stats/cores": "{n} 核心",
+                "stats/na": "不適用",
+                "stats/unavailable": "無法取得",
+                "table/status": "狀態",
+                "table/container": "容器",
+                "table/actions": "操作",
+                "table/repository": "倉庫",
+                "table/tag": "標籤",
+                "table/size": "大小",
+                "table/created": "建立時間",
+                "table/imageId": "映像檔 ID",
+                "table/stack": "堆疊",
+                "table/state": "狀態",
+                "table/autostart": "自動啟動",
+                "table/name": "名稱",
+                "table/stars": "星數",
+                "state/running": "執行中",
+                "state/exited": "已結束",
+                "state/paused": "已暫停",
+                "state/restarting": "重新啟動中",
+                "state/created": "已建立",
+                "state/removing": "移除中",
+                "state/dead": "已失效",
+                "act/start": "啟動",
+                "act/stop": "停止",
+                "act/restart": "重新啟動",
+                "act/console": "主控台",
+                "act/logs": "日誌",
+                "act/publish": "發佈為應用程式",
+                "act/config": "設定",
+                "act/remove": "移除",
+                "act/run": "執行",
+                "act/edit": "編輯",
+                "act/deploy": "部署",
+                "act/delete": "刪除",
+                "verb/start": "啟動",
+                "verb/stop": "停止",
+                "verb/restart": "重新啟動",
+                "verb/remove": "移除",
+                "verb/deploy": "部署",
+                "verb/down": "停止",
+                "toast/actFailed": "{action}失敗:{error}",
+                "toast/actRequestFailed": "{action}請求失敗。",
+                "toast/containerOk": "容器{action}成功。",
+                "toast/stackOk": "堆疊{action}成功。",
+                "containers/rowHint": "點選任一列以查看完整的容器詳細資料。",
+                "containers/none": "找不到任何容器。",
+                "containers/loadFailed": "載入失敗(需要管理員權限)。",
+                "containers/confirmRemove": "要移除容器「{name}」嗎?這會強制停止並刪除該容器,且無法復原。",
+                "containers/confirmRemoveShort": "要移除容器「{name}」嗎?此操作無法復原。",
+                "publish/appTitle": "容器應用程式",
+                "logs/streamError": "日誌串流錯誤。",
+                "logs/openFailed": "無法開啟日誌串流。",
+                "details/noData": "沒有資料。",
+                "details/state": "狀態",
+                "details/image": "映像檔",
+                "details/containerId": "容器 ID",
+                "details/created": "建立時間",
+                "details/restartPolicy": "重新啟動原則",
+                "details/started": "啟動時間",
+                "details/command": "指令",
+                "details/ports": "連接埠",
+                "details/noPorts": "沒有公開的連接埠。",
+                "details/containerPort": "容器連接埠",
+                "details/protocol": "通訊協定",
+                "details/publishedTo": "發佈至(主機)",
+                "details/notPublished": "未發佈",
+                "details/mounts": "掛載",
+                "details/noMounts": "沒有掛載。",
+                "details/source": "來源",
+                "details/destination": "目的地",
+                "details/mode": "模式",
+                "details/networks": "網路",
+                "details/network": "網路",
+                "details/ip": "IP 位址",
+                "details/gateway": "閘道",
+                "details/environment": "環境變數",
+                "images/pull": "下載",
+                "images/none": "找不到任何映像檔。",
+                "images/starting": "開始下載…",
+                "images/finished": "下載完成",
+                "images/streamError": "下載串流錯誤。",
+                "images/startFailed": "無法開始下載。",
+                "images/confirmRemove": "要移除映像檔「{name}」嗎?此操作無法復原。",
+                "images/removeFailed": "移除失敗:{error}",
+                "images/removed": "已移除映像檔。",
+                "images/removeRequestFailed": "移除請求失敗。",
+                "images/enterRef": "請輸入映像檔名稱。",
+                "compose/newStack": "新增堆疊",
+                "compose/unavailable": "此主機未偵測到 Docker Compose v2 外掛程式。請安裝 <code>docker-compose-plugin</code> 以管理堆疊。",
+                "compose/note": "堆疊存放於 <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>。已啟用自動啟動的堆疊會在伺服器開機時啟動。",
+                "compose/none": "尚未有堆疊。按「新增堆疊」建立一個。",
+                "compose/stopped": "已停止",
+                "compose/disabled": "已停用",
+                "compose/enabled": "已啟用",
+                "compose/enable": "啟用",
+                "compose/disable": "停用",
+                "compose/editTitle": "編輯堆疊:{name}",
+                "compose/nameLabel": "堆疊名稱(英文字母、數字、連字號、底線)",
+                "compose/validate": "驗證",
+                "compose/saveDeploy": "儲存並部署",
+                "compose/nameRequired": "必須填寫堆疊名稱。",
+                "compose/nameRequiredValidate": "驗證前必須填寫堆疊名稱。",
+                "compose/saving": "驗證並儲存中…",
+                "compose/deploying": "部署中…下載映像檔可能需要一段時間。",
+                "compose/deployingStack": "正在部署堆疊…可能需要一段時間。",
+                "compose/deployFailed": "部署失敗:{error}",
+                "compose/deployed": "堆疊已部署。",
+                "compose/deployRequestFailed": "部署請求失敗。",
+                "compose/saved": "堆疊已儲存。",
+                "compose/saveRequestFailed": "儲存請求失敗。",
+                "compose/valid": "Compose 檔案有效並已儲存。",
+                "compose/validateRequestFailed": "驗證請求失敗。",
+                "compose/loadStackFailed": "無法載入堆疊。",
+                "compose/logTitle": "堆疊:{name}",
+                "compose/toggleFailed": "切換失敗。",
+                "compose/confirmDelete": "要刪除堆疊「{name}」嗎?這會停止其容器並刪除堆疊檔案,且無法復原。",
+                "compose/deleteFailed": "刪除失敗:{error}",
+                "compose/deleted": "已刪除堆疊。",
+                "compose/deleteRequestFailed": "刪除請求失敗。",
+                "compose/confirmDown": "要停止堆疊「{name}」嗎?這會移除其執行中的容器(堆疊檔案會保留)。",
+                "registry/loginTitle": "登入",
+                "registry/registry": "倉庫(留空 = Docker Hub)",
+                "registry/username": "使用者名稱",
+                "registry/password": "密碼 / 權杖",
+                "registry/login": "登入",
+                "registry/credNote": "憑證由 Docker CLI 儲存,ArozOS 不會保存。密碼只會傳送一次,且不會被記錄。",
+                "registry/configured": "已設定的倉庫",
+                "registry/searchTitle": "搜尋 Docker Hub",
+                "registry/search": "搜尋",
+                "registry/searchHint": "輸入關鍵字後搜尋。",
+                "registry/none": "尚未登入任何倉庫。",
+                "registry/logout": "登出",
+                "registry/credRequired": "需要使用者名稱和密碼。",
+                "registry/loginFailed": "登入失敗:{error}",
+                "registry/loggedIn": "已登入。",
+                "registry/loginRequestFailed": "登入請求失敗。",
+                "registry/logoutFailed": "登出失敗:{error}",
+                "registry/loggedOut": "已登出。",
+                "registry/logoutRequestFailed": "登出請求失敗。",
+                "registry/searching": "搜尋中…",
+                "registry/noResults": "沒有結果。",
+                "registry/official": "官方",
+                "registry/searchFailed": "搜尋失敗(僅限管理員)。",
+                "drawer/container": "容器:",
+                "drawer/logs": "日誌:",
+                "drawer/pulling": "下載中:",
+                "drawer/configure": "設定:",
+                "drawer/console": "主控台:",
+                "run/title": "執行映像檔",
+                "run/recreateTitle": "重建容器",
+                "run/recreateWarn": "<b>重建</b>會刪除現有的容器,並以這些設定啟動新的容器。此操作無法復原。",
+                "run/image": "映像檔",
+                "run/name": "容器名稱(選填)",
+                "run/ports": "連接埠對應(每行一個)",
+                "run/volumes": "磁碟區(src:dst[:mode])",
+                "run/env": "環境變數(每行一個 KEY=value)",
+                "run/restart": "重新啟動原則",
+                "run/network": "網路(選填)",
+                "run/command": "覆寫指令(選填)",
+                "run/createOnly": "建立(不啟動)",
+                "run/run": "執行",
+                "run/recreate": "重建",
+                "run/imageRequired": "必須填寫映像檔。",
+                "run/recreated": "已重建容器。",
+                "run/created": "已建立容器。",
+                "run/started": "容器已啟動。",
+                "cfg/rename": "重新命名",
+                "cfg/liveUpdate": "即時更新",
+                "cfg/liveUpdateNote": "(無需重建即可套用)",
+                "cfg/unchanged": "(不變更)",
+                "cfg/memory": "記憶體上限",
+                "cfg/cpus": "CPU 數量",
+                "cfg/recreateNote": "變更連接埠、磁碟區或環境變數需要<b>重建</b>容器。",
+                "cfg/recreateLink": "以新設定重建…",
+                "cfg/apply": "套用即時更新",
+                "cfg/enterName": "請輸入新名稱。",
+                "cfg/renameFailed": "重新命名失敗:{error}",
+                "cfg/renamed": "已重新命名。",
+                "cfg/renameRequestFailed": "重新命名請求失敗。",
+                "cfg/nothing": "沒有需要更新的項目。",
+                "cfg/updateFailed": "更新失敗:{error}",
+                "cfg/updated": "已更新。",
+                "cfg/updateRequestFailed": "更新請求失敗。",
+                "console/warn": "您已連線至<b>此容器內的即時 Shell</b>。指令會以容器的權限執行,並可能影響主機。",
+                "console/shell": "Shell:",
+                "console/reconnect": "重新連線",
+                "console/closed": "工作階段已結束",
+                "console/connError": "主控台連線錯誤。"
+            },
+            "titles": {
+                "Open in window": "在視窗中開啟"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine 或 registry/repo:tag",
+                "e.g. nginx": "例如 nginx"
+            }
+        },
+        "zh-cn": {
+            "name": "简体中文",
+            "fwtitle": "Docker 管理器",
+            "strings": {
+                "common/cancel": "取消",
+                "common/close": "关闭",
+                "common/save": "保存",
+                "common/refresh": "刷新",
+                "common/failed": "失败:{error}",
+                "common/requestFailed": "请求失败。",
+                "common/loadFailedAdmin": "加载失败(仅限管理员)。",
+                "app/title": "Docker 管理器",
+                "common/loading": "加载中…",
+                "nav/containers": "容器",
+                "nav/images": "镜像",
+                "nav/compose": "Compose 堆栈",
+                "nav/registries": "镜像仓库",
+                "nav/adminNote": "仅限管理员。Docker 访问权限等同于此主机的 root 权限。",
+                "stats/cpu": "CPU",
+                "stats/ram": "内存",
+                "stats/storage": "存储",
+                "stats/load": "负载",
+                "stats/cores": "{n} 核",
+                "stats/na": "不适用",
+                "stats/unavailable": "不可用",
+                "table/status": "状态",
+                "table/container": "容器",
+                "table/actions": "操作",
+                "table/repository": "仓库",
+                "table/tag": "标签",
+                "table/size": "大小",
+                "table/created": "创建时间",
+                "table/imageId": "镜像 ID",
+                "table/stack": "堆栈",
+                "table/state": "状态",
+                "table/autostart": "自动启动",
+                "table/name": "名称",
+                "table/stars": "星标",
+                "state/running": "运行中",
+                "state/exited": "已退出",
+                "state/paused": "已暂停",
+                "state/restarting": "重启中",
+                "state/created": "已创建",
+                "state/removing": "移除中",
+                "state/dead": "已失效",
+                "act/start": "启动",
+                "act/stop": "停止",
+                "act/restart": "重启",
+                "act/console": "控制台",
+                "act/logs": "日志",
+                "act/publish": "发布为应用",
+                "act/config": "配置",
+                "act/remove": "移除",
+                "act/run": "运行",
+                "act/edit": "编辑",
+                "act/deploy": "部署",
+                "act/delete": "删除",
+                "verb/start": "启动",
+                "verb/stop": "停止",
+                "verb/restart": "重启",
+                "verb/remove": "移除",
+                "verb/deploy": "部署",
+                "verb/down": "停止",
+                "toast/actFailed": "{action}失败:{error}",
+                "toast/actRequestFailed": "{action}请求失败。",
+                "toast/containerOk": "容器{action}成功。",
+                "toast/stackOk": "堆栈{action}成功。",
+                "containers/rowHint": "点击任意行查看完整的容器详细信息。",
+                "containers/none": "未找到容器。",
+                "containers/loadFailed": "加载失败(需要管理员权限)。",
+                "containers/confirmRemove": "要移除容器“{name}”吗?这将强制停止并删除该容器,且无法撤销。",
+                "containers/confirmRemoveShort": "要移除容器“{name}”吗?此操作无法撤销。",
+                "publish/appTitle": "容器应用",
+                "logs/streamError": "日志流错误。",
+                "logs/openFailed": "无法打开日志流。",
+                "details/noData": "没有数据。",
+                "details/state": "状态",
+                "details/image": "镜像",
+                "details/containerId": "容器 ID",
+                "details/created": "创建时间",
+                "details/restartPolicy": "重启策略",
+                "details/started": "启动时间",
+                "details/command": "命令",
+                "details/ports": "端口",
+                "details/noPorts": "没有暴露的端口。",
+                "details/containerPort": "容器端口",
+                "details/protocol": "协议",
+                "details/publishedTo": "发布到(主机)",
+                "details/notPublished": "未发布",
+                "details/mounts": "挂载",
+                "details/noMounts": "没有挂载。",
+                "details/source": "源",
+                "details/destination": "目标",
+                "details/mode": "模式",
+                "details/networks": "网络",
+                "details/network": "网络",
+                "details/ip": "IP 地址",
+                "details/gateway": "网关",
+                "details/environment": "环境变量",
+                "images/pull": "拉取",
+                "images/none": "未找到镜像。",
+                "images/starting": "开始拉取…",
+                "images/finished": "拉取完成",
+                "images/streamError": "拉取流错误。",
+                "images/startFailed": "无法开始拉取。",
+                "images/confirmRemove": "要移除镜像“{name}”吗?此操作无法撤销。",
+                "images/removeFailed": "移除失败:{error}",
+                "images/removed": "已移除镜像。",
+                "images/removeRequestFailed": "移除请求失败。",
+                "images/enterRef": "请输入镜像名称。",
+                "compose/newStack": "新建堆栈",
+                "compose/unavailable": "此主机未检测到 Docker Compose v2 插件。请安装 <code>docker-compose-plugin</code> 以管理堆栈。",
+                "compose/note": "堆栈存放在 <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>。已启用自动启动的堆栈会在服务器启动时启动。",
+                "compose/none": "暂无堆栈。点击“新建堆栈”创建一个。",
+                "compose/stopped": "已停止",
+                "compose/disabled": "已禁用",
+                "compose/enabled": "已启用",
+                "compose/enable": "启用",
+                "compose/disable": "禁用",
+                "compose/editTitle": "编辑堆栈:{name}",
+                "compose/nameLabel": "堆栈名称(字母、数字、连字符、下划线)",
+                "compose/validate": "验证",
+                "compose/saveDeploy": "保存并部署",
+                "compose/nameRequired": "必须填写堆栈名称。",
+                "compose/nameRequiredValidate": "验证前必须填写堆栈名称。",
+                "compose/saving": "正在验证并保存…",
+                "compose/deploying": "部署中…拉取镜像可能需要一段时间。",
+                "compose/deployingStack": "正在部署堆栈…可能需要一段时间。",
+                "compose/deployFailed": "部署失败:{error}",
+                "compose/deployed": "堆栈已部署。",
+                "compose/deployRequestFailed": "部署请求失败。",
+                "compose/saved": "堆栈已保存。",
+                "compose/saveRequestFailed": "保存请求失败。",
+                "compose/valid": "Compose 文件有效并已保存。",
+                "compose/validateRequestFailed": "验证请求失败。",
+                "compose/loadStackFailed": "无法加载堆栈。",
+                "compose/logTitle": "堆栈:{name}",
+                "compose/toggleFailed": "切换失败。",
+                "compose/confirmDelete": "要删除堆栈“{name}”吗?这将停止其容器并删除堆栈文件,且无法撤销。",
+                "compose/deleteFailed": "删除失败:{error}",
+                "compose/deleted": "已删除堆栈。",
+                "compose/deleteRequestFailed": "删除请求失败。",
+                "compose/confirmDown": "要停止堆栈“{name}”吗?这将移除其运行中的容器(堆栈文件会保留)。",
+                "registry/loginTitle": "登录",
+                "registry/registry": "仓库(留空 = Docker Hub)",
+                "registry/username": "用户名",
+                "registry/password": "密码 / 令牌",
+                "registry/login": "登录",
+                "registry/credNote": "凭据由 Docker CLI 保存,ArozOS 不会保存。密码只发送一次,且不会被记录。",
+                "registry/configured": "已配置的仓库",
+                "registry/searchTitle": "搜索 Docker Hub",
+                "registry/search": "搜索",
+                "registry/searchHint": "输入关键字后搜索。",
+                "registry/none": "尚未登录任何仓库。",
+                "registry/logout": "退出",
+                "registry/credRequired": "需要用户名和密码。",
+                "registry/loginFailed": "登录失败:{error}",
+                "registry/loggedIn": "已登录。",
+                "registry/loginRequestFailed": "登录请求失败。",
+                "registry/logoutFailed": "退出失败:{error}",
+                "registry/loggedOut": "已退出登录。",
+                "registry/logoutRequestFailed": "退出请求失败。",
+                "registry/searching": "搜索中…",
+                "registry/noResults": "没有结果。",
+                "registry/official": "官方",
+                "registry/searchFailed": "搜索失败(仅限管理员)。",
+                "drawer/container": "容器:",
+                "drawer/logs": "日志:",
+                "drawer/pulling": "正在拉取:",
+                "drawer/configure": "配置:",
+                "drawer/console": "控制台:",
+                "run/title": "运行镜像",
+                "run/recreateTitle": "重建容器",
+                "run/recreateWarn": "<b>重建</b>将删除现有容器,并使用这些设置启动新容器。此操作无法撤销。",
+                "run/image": "镜像",
+                "run/name": "容器名称(可选)",
+                "run/ports": "端口映射(每行一个)",
+                "run/volumes": "卷(src:dst[:mode])",
+                "run/env": "环境变量(每行一个 KEY=value)",
+                "run/restart": "重启策略",
+                "run/network": "网络(可选)",
+                "run/command": "覆盖命令(可选)",
+                "run/createOnly": "创建(不启动)",
+                "run/run": "运行",
+                "run/recreate": "重建",
+                "run/imageRequired": "必须填写镜像。",
+                "run/recreated": "已重建容器。",
+                "run/created": "已创建容器。",
+                "run/started": "容器已启动。",
+                "cfg/rename": "重命名",
+                "cfg/liveUpdate": "实时更新",
+                "cfg/liveUpdateNote": "(无需重建即可应用)",
+                "cfg/unchanged": "(不更改)",
+                "cfg/memory": "内存上限",
+                "cfg/cpus": "CPU 数量",
+                "cfg/recreateNote": "更改端口、卷或环境变量需要<b>重建</b>容器。",
+                "cfg/recreateLink": "使用新设置重建…",
+                "cfg/apply": "应用实时更新",
+                "cfg/enterName": "请输入新名称。",
+                "cfg/renameFailed": "重命名失败:{error}",
+                "cfg/renamed": "已重命名。",
+                "cfg/renameRequestFailed": "重命名请求失败。",
+                "cfg/nothing": "没有需要更新的项目。",
+                "cfg/updateFailed": "更新失败:{error}",
+                "cfg/updated": "已更新。",
+                "cfg/updateRequestFailed": "更新请求失败。",
+                "console/warn": "您已连接到<b>此容器内的实时 Shell</b>。命令将以容器的权限运行,并可能影响主机。",
+                "console/shell": "Shell:",
+                "console/reconnect": "重新连接",
+                "console/closed": "会话已关闭",
+                "console/connError": "控制台连接错误。"
+            },
+            "titles": {
+                "Open in window": "在窗口中打开"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine 或 registry/repo:tag",
+                "e.g. nginx": "例如 nginx"
+            }
+        },
+        "en-us": {
+            "name": "English (US)",
+            "fwtitle": "Docker Manager",
+            "strings": {
+                "common/cancel": "Cancel",
+                "common/close": "Close",
+                "common/save": "Save",
+                "common/refresh": "Refresh",
+                "common/failed": "Failed: {error}",
+                "common/requestFailed": "Request failed.",
+                "common/loadFailedAdmin": "Failed to load (admin only).",
+                "app/title": "Docker Manager",
+                "common/loading": "Loading…",
+                "nav/containers": "Containers",
+                "nav/images": "Images",
+                "nav/compose": "Compose Stacks",
+                "nav/registries": "Registries",
+                "nav/adminNote": "Admin-only. Docker access is root-equivalent on this host.",
+                "stats/cpu": "CPU",
+                "stats/ram": "RAM",
+                "stats/storage": "Storage",
+                "stats/load": "Load",
+                "stats/cores": "{n} cores",
+                "stats/na": "n/a",
+                "stats/unavailable": "unavailable",
+                "table/status": "Status",
+                "table/container": "Container",
+                "table/actions": "Actions",
+                "table/repository": "Repository",
+                "table/tag": "Tag",
+                "table/size": "Size",
+                "table/created": "Created",
+                "table/imageId": "Image ID",
+                "table/stack": "Stack",
+                "table/state": "State",
+                "table/autostart": "Auto-start",
+                "table/name": "Name",
+                "table/stars": "Stars",
+                "state/running": "running",
+                "state/exited": "exited",
+                "state/paused": "paused",
+                "state/restarting": "restarting",
+                "state/created": "created",
+                "state/removing": "removing",
+                "state/dead": "dead",
+                "act/start": "Start",
+                "act/stop": "Stop",
+                "act/restart": "Restart",
+                "act/console": "Console",
+                "act/logs": "Logs",
+                "act/publish": "Publish as app",
+                "act/config": "Configure",
+                "act/remove": "Remove",
+                "act/run": "Run",
+                "act/edit": "Edit",
+                "act/deploy": "Deploy",
+                "act/delete": "Delete",
+                "verb/start": "start",
+                "verb/stop": "stop",
+                "verb/restart": "restart",
+                "verb/remove": "remove",
+                "verb/deploy": "deploy",
+                "verb/down": "down",
+                "toast/actFailed": "{action} failed: {error}",
+                "toast/actRequestFailed": "{action} request failed.",
+                "toast/containerOk": "Container {action} ok.",
+                "toast/stackOk": "Stack {action} ok.",
+                "containers/rowHint": "Click a row to open full container details.",
+                "containers/none": "No containers found.",
+                "containers/loadFailed": "Failed to load (administrator access required).",
+                "containers/confirmRemove": "Remove container \"{name}\"? This force-stops and deletes it. This cannot be undone.",
+                "containers/confirmRemoveShort": "Remove container \"{name}\"? This cannot be undone.",
+                "publish/appTitle": "Container Apps",
+                "logs/streamError": "Log stream error.",
+                "logs/openFailed": "Unable to open log stream.",
+                "details/noData": "No data.",
+                "details/state": "State",
+                "details/image": "Image",
+                "details/containerId": "Container ID",
+                "details/created": "Created",
+                "details/restartPolicy": "Restart Policy",
+                "details/started": "Started",
+                "details/command": "Command",
+                "details/ports": "Ports",
+                "details/noPorts": "No exposed ports.",
+                "details/containerPort": "Container Port",
+                "details/protocol": "Protocol",
+                "details/publishedTo": "Published To (host)",
+                "details/notPublished": "not published",
+                "details/mounts": "Mounts",
+                "details/noMounts": "No mounts.",
+                "details/source": "Source",
+                "details/destination": "Destination",
+                "details/mode": "Mode",
+                "details/networks": "Networks",
+                "details/network": "Network",
+                "details/ip": "IP Address",
+                "details/gateway": "Gateway",
+                "details/environment": "Environment",
+                "images/pull": "Pull",
+                "images/none": "No images found.",
+                "images/starting": "Starting pull…",
+                "images/finished": "pull finished",
+                "images/streamError": "Pull stream error.",
+                "images/startFailed": "Unable to start pull.",
+                "images/confirmRemove": "Remove image \"{name}\"? This cannot be undone.",
+                "images/removeFailed": "Remove failed: {error}",
+                "images/removed": "Image removed.",
+                "images/removeRequestFailed": "Remove request failed.",
+                "images/enterRef": "Enter an image reference.",
+                "compose/newStack": "New Stack",
+                "compose/unavailable": "The Docker Compose v2 plugin was not detected on this host. Install <code>docker-compose-plugin</code> to manage stacks.",
+                "compose/note": "Stacks live in <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>. Stacks with auto-start enabled are brought up on server boot.",
+                "compose/none": "No stacks yet. Click \"New Stack\" to create one.",
+                "compose/stopped": "stopped",
+                "compose/disabled": "disabled",
+                "compose/enabled": "enabled",
+                "compose/enable": "Enable",
+                "compose/disable": "Disable",
+                "compose/editTitle": "Edit Stack: {name}",
+                "compose/nameLabel": "Stack name (letters, digits, dash, underscore)",
+                "compose/validate": "Validate",
+                "compose/saveDeploy": "Save &amp; Deploy",
+                "compose/nameRequired": "Stack name is required.",
+                "compose/nameRequiredValidate": "Stack name is required to validate.",
+                "compose/saving": "Validating & saving…",
+                "compose/deploying": "Deploying… pulling images may take a while.",
+                "compose/deployingStack": "Deploying stack… this can take a while.",
+                "compose/deployFailed": "Deploy failed: {error}",
+                "compose/deployed": "Stack deployed.",
+                "compose/deployRequestFailed": "Deploy request failed.",
+                "compose/saved": "Stack saved.",
+                "compose/saveRequestFailed": "Save request failed.",
+                "compose/valid": "Compose file is valid and saved.",
+                "compose/validateRequestFailed": "Validate request failed.",
+                "compose/loadStackFailed": "Failed to load stack.",
+                "compose/logTitle": "stack: {name}",
+                "compose/toggleFailed": "Toggle failed.",
+                "compose/confirmDelete": "Delete stack \"{name}\"? This stops its containers and deletes the stack files. This cannot be undone.",
+                "compose/deleteFailed": "Delete failed: {error}",
+                "compose/deleted": "Stack deleted.",
+                "compose/deleteRequestFailed": "Delete request failed.",
+                "compose/confirmDown": "Stop stack \"{name}\"? This removes its running containers (the stack files are kept).",
+                "registry/loginTitle": "Login",
+                "registry/registry": "Registry (blank = Docker Hub)",
+                "registry/username": "Username",
+                "registry/password": "Password / Token",
+                "registry/login": "Login",
+                "registry/credNote": "Credentials are stored by the Docker CLI, never by ArozOS. The password is sent once and never logged.",
+                "registry/configured": "Configured registries",
+                "registry/searchTitle": "Search Docker Hub",
+                "registry/search": "Search",
+                "registry/searchHint": "Enter a term and search.",
+                "registry/none": "No registries logged in.",
+                "registry/logout": "Logout",
+                "registry/credRequired": "Username and password required.",
+                "registry/loginFailed": "Login failed: {error}",
+                "registry/loggedIn": "Logged in.",
+                "registry/loginRequestFailed": "Login request failed.",
+                "registry/logoutFailed": "Logout failed: {error}",
+                "registry/loggedOut": "Logged out.",
+                "registry/logoutRequestFailed": "Logout request failed.",
+                "registry/searching": "Searching…",
+                "registry/noResults": "No results.",
+                "registry/official": "official",
+                "registry/searchFailed": "Search failed (admin only).",
+                "drawer/container": "Container:",
+                "drawer/logs": "Logs:",
+                "drawer/pulling": "Pulling:",
+                "drawer/configure": "Configure:",
+                "drawer/console": "Console:",
+                "run/title": "Run Image",
+                "run/recreateTitle": "Recreate Container",
+                "run/recreateWarn": "<b>Recreate</b> deletes the existing container and launches a new one with these settings. This cannot be undone.",
+                "run/image": "Image",
+                "run/name": "Container name (optional)",
+                "run/ports": "Port mappings (one per line)",
+                "run/volumes": "Volumes (src:dst[:mode])",
+                "run/env": "Environment variables (KEY=value per line)",
+                "run/restart": "Restart policy",
+                "run/network": "Network (optional)",
+                "run/command": "Command override (optional)",
+                "run/createOnly": "Create (don't start)",
+                "run/run": "Run",
+                "run/recreate": "Recreate",
+                "run/imageRequired": "Image is required.",
+                "run/recreated": "Container recreated.",
+                "run/created": "Container created.",
+                "run/started": "Container started.",
+                "cfg/rename": "Rename",
+                "cfg/liveUpdate": "Live update",
+                "cfg/liveUpdateNote": "(applied without recreating)",
+                "cfg/unchanged": "(unchanged)",
+                "cfg/memory": "Memory limit",
+                "cfg/cpus": "CPUs",
+                "cfg/recreateNote": "Changing ports, volumes or environment needs a <b>recreate</b>.",
+                "cfg/recreateLink": "Recreate with new settings…",
+                "cfg/apply": "Apply live update",
+                "cfg/enterName": "Enter a new name.",
+                "cfg/renameFailed": "Rename failed: {error}",
+                "cfg/renamed": "Renamed.",
+                "cfg/renameRequestFailed": "Rename request failed.",
+                "cfg/nothing": "Nothing to update.",
+                "cfg/updateFailed": "Update failed: {error}",
+                "cfg/updated": "Updated.",
+                "cfg/updateRequestFailed": "Update request failed.",
+                "console/warn": "You are connected to a <b>live shell inside this container</b>. Commands run with the container's privileges and can affect the host.",
+                "console/shell": "Shell:",
+                "console/reconnect": "Reconnect",
+                "console/closed": "session closed",
+                "console/connError": "Console connection error."
+            },
+            "titles": {
+                "Open in window": "Open in window"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine or registry/repo:tag",
+                "e.g. nginx": "e.g. nginx"
+            }
+        },
+        "ja-jp": {
+            "name": "日本語",
+            "fwtitle": "Docker マネージャー",
+            "strings": {
+                "common/cancel": "キャンセル",
+                "common/close": "閉じる",
+                "common/save": "保存",
+                "common/refresh": "更新",
+                "common/failed": "失敗しました: {error}",
+                "common/requestFailed": "リクエストに失敗しました。",
+                "common/loadFailedAdmin": "読み込みに失敗しました(管理者のみ)。",
+                "app/title": "Docker マネージャー",
+                "common/loading": "読み込み中…",
+                "nav/containers": "コンテナ",
+                "nav/images": "イメージ",
+                "nav/compose": "Compose スタック",
+                "nav/registries": "レジストリ",
+                "nav/adminNote": "管理者専用。Docker へのアクセスはこのホストの root 権限に相当します。",
+                "stats/cpu": "CPU",
+                "stats/ram": "メモリ",
+                "stats/storage": "ストレージ",
+                "stats/load": "負荷",
+                "stats/cores": "{n} コア",
+                "stats/na": "なし",
+                "stats/unavailable": "利用不可",
+                "table/status": "ステータス",
+                "table/container": "コンテナ",
+                "table/actions": "操作",
+                "table/repository": "リポジトリ",
+                "table/tag": "タグ",
+                "table/size": "サイズ",
+                "table/created": "作成",
+                "table/imageId": "イメージ ID",
+                "table/stack": "スタック",
+                "table/state": "状態",
+                "table/autostart": "自動起動",
+                "table/name": "名前",
+                "table/stars": "スター",
+                "state/running": "実行中",
+                "state/exited": "終了",
+                "state/paused": "一時停止",
+                "state/restarting": "再起動中",
+                "state/created": "作成済み",
+                "state/removing": "削除中",
+                "state/dead": "異常停止",
+                "act/start": "開始",
+                "act/stop": "停止",
+                "act/restart": "再起動",
+                "act/console": "コンソール",
+                "act/logs": "ログ",
+                "act/publish": "アプリとして公開",
+                "act/config": "設定",
+                "act/remove": "削除",
+                "act/run": "実行",
+                "act/edit": "編集",
+                "act/deploy": "デプロイ",
+                "act/delete": "削除",
+                "verb/start": "起動",
+                "verb/stop": "停止",
+                "verb/restart": "再起動",
+                "verb/remove": "削除",
+                "verb/deploy": "デプロイ",
+                "verb/down": "停止",
+                "toast/actFailed": "{action}に失敗しました: {error}",
+                "toast/actRequestFailed": "{action}のリクエストに失敗しました。",
+                "toast/containerOk": "コンテナの{action}が完了しました。",
+                "toast/stackOk": "スタックの{action}が完了しました。",
+                "containers/rowHint": "行をクリックするとコンテナの詳細を表示します。",
+                "containers/none": "コンテナが見つかりません。",
+                "containers/loadFailed": "読み込みに失敗しました(管理者権限が必要です)。",
+                "containers/confirmRemove": "コンテナ「{name}」を削除しますか?強制停止して削除します。この操作は元に戻せません。",
+                "containers/confirmRemoveShort": "コンテナ「{name}」を削除しますか?この操作は元に戻せません。",
+                "publish/appTitle": "コンテナアプリ",
+                "logs/streamError": "ログストリームのエラー。",
+                "logs/openFailed": "ログストリームを開けません。",
+                "details/noData": "データがありません。",
+                "details/state": "状態",
+                "details/image": "イメージ",
+                "details/containerId": "コンテナ ID",
+                "details/created": "作成日時",
+                "details/restartPolicy": "再起動ポリシー",
+                "details/started": "開始日時",
+                "details/command": "コマンド",
+                "details/ports": "ポート",
+                "details/noPorts": "公開ポートはありません。",
+                "details/containerPort": "コンテナポート",
+                "details/protocol": "プロトコル",
+                "details/publishedTo": "公開先(ホスト)",
+                "details/notPublished": "未公開",
+                "details/mounts": "マウント",
+                "details/noMounts": "マウントはありません。",
+                "details/source": "ソース",
+                "details/destination": "マウント先",
+                "details/mode": "モード",
+                "details/networks": "ネットワーク",
+                "details/network": "ネットワーク",
+                "details/ip": "IP アドレス",
+                "details/gateway": "ゲートウェイ",
+                "details/environment": "環境変数",
+                "images/pull": "取得",
+                "images/none": "イメージが見つかりません。",
+                "images/starting": "取得を開始しています…",
+                "images/finished": "取得完了",
+                "images/streamError": "取得ストリームのエラー。",
+                "images/startFailed": "取得を開始できません。",
+                "images/confirmRemove": "イメージ「{name}」を削除しますか?この操作は元に戻せません。",
+                "images/removeFailed": "削除に失敗しました: {error}",
+                "images/removed": "イメージを削除しました。",
+                "images/removeRequestFailed": "削除リクエストに失敗しました。",
+                "images/enterRef": "イメージ名を入力してください。",
+                "compose/newStack": "新しいスタック",
+                "compose/unavailable": "このホストで Docker Compose v2 プラグインが見つかりません。スタックを管理するには <code>docker-compose-plugin</code> をインストールしてください。",
+                "compose/note": "スタックは <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code> に保存されます。自動起動が有効なスタックはサーバー起動時に立ち上がります。",
+                "compose/none": "スタックはまだありません。「新しいスタック」をクリックして作成します。",
+                "compose/stopped": "停止",
+                "compose/disabled": "無効",
+                "compose/enabled": "有効",
+                "compose/enable": "有効化",
+                "compose/disable": "無効化",
+                "compose/editTitle": "スタックを編集: {name}",
+                "compose/nameLabel": "スタック名(英字、数字、ハイフン、アンダースコア)",
+                "compose/validate": "検証",
+                "compose/saveDeploy": "保存してデプロイ",
+                "compose/nameRequired": "スタック名は必須です。",
+                "compose/nameRequiredValidate": "検証にはスタック名が必要です。",
+                "compose/saving": "検証して保存中…",
+                "compose/deploying": "デプロイ中… イメージの取得に時間がかかる場合があります。",
+                "compose/deployingStack": "スタックをデプロイ中… 時間がかかる場合があります。",
+                "compose/deployFailed": "デプロイに失敗しました: {error}",
+                "compose/deployed": "スタックをデプロイしました。",
+                "compose/deployRequestFailed": "デプロイのリクエストに失敗しました。",
+                "compose/saved": "スタックを保存しました。",
+                "compose/saveRequestFailed": "保存リクエストに失敗しました。",
+                "compose/valid": "Compose ファイルは有効で、保存されました。",
+                "compose/validateRequestFailed": "検証リクエストに失敗しました。",
+                "compose/loadStackFailed": "スタックを読み込めませんでした。",
+                "compose/logTitle": "スタック: {name}",
+                "compose/toggleFailed": "切り替えに失敗しました。",
+                "compose/confirmDelete": "スタック「{name}」を削除しますか?コンテナを停止し、スタックのファイルを削除します。この操作は元に戻せません。",
+                "compose/deleteFailed": "削除に失敗しました: {error}",
+                "compose/deleted": "スタックを削除しました。",
+                "compose/deleteRequestFailed": "削除リクエストに失敗しました。",
+                "compose/confirmDown": "スタック「{name}」を停止しますか?実行中のコンテナは削除されます(スタックのファイルは残ります)。",
+                "registry/loginTitle": "ログイン",
+                "registry/registry": "レジストリ(空欄 = Docker Hub)",
+                "registry/username": "ユーザー名",
+                "registry/password": "パスワード / トークン",
+                "registry/login": "ログイン",
+                "registry/credNote": "認証情報は Docker CLI が保存し、ArozOS には保存されません。パスワードは一度だけ送信され、記録されません。",
+                "registry/configured": "設定済みのレジストリ",
+                "registry/searchTitle": "Docker Hub を検索",
+                "registry/search": "検索",
+                "registry/searchHint": "キーワードを入力して検索します。",
+                "registry/none": "ログイン中のレジストリはありません。",
+                "registry/logout": "ログアウト",
+                "registry/credRequired": "ユーザー名とパスワードが必要です。",
+                "registry/loginFailed": "ログインに失敗しました: {error}",
+                "registry/loggedIn": "ログインしました。",
+                "registry/loginRequestFailed": "ログインリクエストに失敗しました。",
+                "registry/logoutFailed": "ログアウトに失敗しました: {error}",
+                "registry/loggedOut": "ログアウトしました。",
+                "registry/logoutRequestFailed": "ログアウトリクエストに失敗しました。",
+                "registry/searching": "検索中…",
+                "registry/noResults": "結果がありません。",
+                "registry/official": "公式",
+                "registry/searchFailed": "検索に失敗しました(管理者のみ)。",
+                "drawer/container": "コンテナ:",
+                "drawer/logs": "ログ:",
+                "drawer/pulling": "取得中:",
+                "drawer/configure": "設定:",
+                "drawer/console": "コンソール:",
+                "run/title": "イメージを実行",
+                "run/recreateTitle": "コンテナを再作成",
+                "run/recreateWarn": "<b>再作成</b>すると既存のコンテナを削除し、この設定で新しいコンテナを起動します。この操作は元に戻せません。",
+                "run/image": "イメージ",
+                "run/name": "コンテナ名(任意)",
+                "run/ports": "ポートマッピング(1 行に 1 つ)",
+                "run/volumes": "ボリューム(src:dst[:mode])",
+                "run/env": "環境変数(1 行に 1 つ KEY=value)",
+                "run/restart": "再起動ポリシー",
+                "run/network": "ネットワーク(任意)",
+                "run/command": "コマンドの上書き(任意)",
+                "run/createOnly": "作成(起動しない)",
+                "run/run": "実行",
+                "run/recreate": "再作成",
+                "run/imageRequired": "イメージは必須です。",
+                "run/recreated": "コンテナを再作成しました。",
+                "run/created": "コンテナを作成しました。",
+                "run/started": "コンテナを起動しました。",
+                "cfg/rename": "名前を変更",
+                "cfg/liveUpdate": "ライブ更新",
+                "cfg/liveUpdateNote": "(再作成せずに適用)",
+                "cfg/unchanged": "(変更なし)",
+                "cfg/memory": "メモリ上限",
+                "cfg/cpus": "CPU 数",
+                "cfg/recreateNote": "ポート、ボリューム、環境変数を変更するには<b>再作成</b>が必要です。",
+                "cfg/recreateLink": "新しい設定で再作成…",
+                "cfg/apply": "ライブ更新を適用",
+                "cfg/enterName": "新しい名前を入力してください。",
+                "cfg/renameFailed": "名前の変更に失敗しました: {error}",
+                "cfg/renamed": "名前を変更しました。",
+                "cfg/renameRequestFailed": "名前変更のリクエストに失敗しました。",
+                "cfg/nothing": "更新する項目がありません。",
+                "cfg/updateFailed": "更新に失敗しました: {error}",
+                "cfg/updated": "更新しました。",
+                "cfg/updateRequestFailed": "更新リクエストに失敗しました。",
+                "console/warn": "<b>このコンテナ内のライブシェル</b>に接続しています。コマンドはコンテナの権限で実行され、ホストに影響する可能性があります。",
+                "console/shell": "シェル:",
+                "console/reconnect": "再接続",
+                "console/closed": "セッションが終了しました",
+                "console/connError": "コンソールの接続エラー。"
+            },
+            "titles": {
+                "Open in window": "ウィンドウで開く"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine または registry/repo:tag",
+                "e.g. nginx": "例: nginx"
+            }
+        },
+        "ko-kr": {
+            "name": "한국어",
+            "fwtitle": "Docker 관리자",
+            "strings": {
+                "common/cancel": "취소",
+                "common/close": "닫기",
+                "common/save": "저장",
+                "common/refresh": "새로 고침",
+                "common/failed": "실패: {error}",
+                "common/requestFailed": "요청에 실패했습니다.",
+                "common/loadFailedAdmin": "불러오기 실패 (관리자 전용).",
+                "app/title": "Docker 관리자",
+                "common/loading": "불러오는 중…",
+                "nav/containers": "컨테이너",
+                "nav/images": "이미지",
+                "nav/compose": "Compose 스택",
+                "nav/registries": "레지스트리",
+                "nav/adminNote": "관리자 전용. Docker 접근 권한은 이 호스트의 root 권한과 같습니다.",
+                "stats/cpu": "CPU",
+                "stats/ram": "메모리",
+                "stats/storage": "스토리지",
+                "stats/load": "부하",
+                "stats/cores": "{n}코어",
+                "stats/na": "해당 없음",
+                "stats/unavailable": "사용할 수 없음",
+                "table/status": "상태",
+                "table/container": "컨테이너",
+                "table/actions": "작업",
+                "table/repository": "저장소",
+                "table/tag": "태그",
+                "table/size": "크기",
+                "table/created": "생성",
+                "table/imageId": "이미지 ID",
+                "table/stack": "스택",
+                "table/state": "상태",
+                "table/autostart": "자동 시작",
+                "table/name": "이름",
+                "table/stars": "스타",
+                "state/running": "실행 중",
+                "state/exited": "종료됨",
+                "state/paused": "일시 중지됨",
+                "state/restarting": "재시작 중",
+                "state/created": "생성됨",
+                "state/removing": "제거 중",
+                "state/dead": "비정상 종료",
+                "act/start": "시작",
+                "act/stop": "중지",
+                "act/restart": "재시작",
+                "act/console": "콘솔",
+                "act/logs": "로그",
+                "act/publish": "앱으로 게시",
+                "act/config": "구성",
+                "act/remove": "제거",
+                "act/run": "실행",
+                "act/edit": "편집",
+                "act/deploy": "배포",
+                "act/delete": "삭제",
+                "verb/start": "시작",
+                "verb/stop": "중지",
+                "verb/restart": "재시작",
+                "verb/remove": "제거",
+                "verb/deploy": "배포",
+                "verb/down": "중지",
+                "toast/actFailed": "{action} 실패: {error}",
+                "toast/actRequestFailed": "{action} 요청에 실패했습니다.",
+                "toast/containerOk": "컨테이너 {action} 완료.",
+                "toast/stackOk": "스택 {action} 완료.",
+                "containers/rowHint": "행을 클릭하면 컨테이너 세부 정보를 볼 수 있습니다.",
+                "containers/none": "컨테이너가 없습니다.",
+                "containers/loadFailed": "불러오기 실패 (관리자 권한 필요).",
+                "containers/confirmRemove": "컨테이너 \"{name}\"을(를) 제거하시겠습니까? 강제로 중지하고 삭제합니다. 되돌릴 수 없습니다.",
+                "containers/confirmRemoveShort": "컨테이너 \"{name}\"을(를) 제거하시겠습니까? 되돌릴 수 없습니다.",
+                "publish/appTitle": "컨테이너 앱",
+                "logs/streamError": "로그 스트림 오류.",
+                "logs/openFailed": "로그 스트림을 열 수 없습니다.",
+                "details/noData": "데이터가 없습니다.",
+                "details/state": "상태",
+                "details/image": "이미지",
+                "details/containerId": "컨테이너 ID",
+                "details/created": "생성 시각",
+                "details/restartPolicy": "재시작 정책",
+                "details/started": "시작 시각",
+                "details/command": "명령",
+                "details/ports": "포트",
+                "details/noPorts": "노출된 포트가 없습니다.",
+                "details/containerPort": "컨테이너 포트",
+                "details/protocol": "프로토콜",
+                "details/publishedTo": "게시 대상 (호스트)",
+                "details/notPublished": "게시되지 않음",
+                "details/mounts": "마운트",
+                "details/noMounts": "마운트가 없습니다.",
+                "details/source": "원본",
+                "details/destination": "대상",
+                "details/mode": "모드",
+                "details/networks": "네트워크",
+                "details/network": "네트워크",
+                "details/ip": "IP 주소",
+                "details/gateway": "게이트웨이",
+                "details/environment": "환경 변수",
+                "images/pull": "가져오기",
+                "images/none": "이미지가 없습니다.",
+                "images/starting": "가져오기 시작 중…",
+                "images/finished": "가져오기 완료",
+                "images/streamError": "가져오기 스트림 오류.",
+                "images/startFailed": "가져오기를 시작할 수 없습니다.",
+                "images/confirmRemove": "이미지 \"{name}\"을(를) 제거하시겠습니까? 되돌릴 수 없습니다.",
+                "images/removeFailed": "제거 실패: {error}",
+                "images/removed": "이미지가 제거되었습니다.",
+                "images/removeRequestFailed": "제거 요청에 실패했습니다.",
+                "images/enterRef": "이미지 이름을 입력하세요.",
+                "compose/newStack": "새 스택",
+                "compose/unavailable": "이 호스트에서 Docker Compose v2 플러그인을 찾을 수 없습니다. 스택을 관리하려면 <code>docker-compose-plugin</code>을 설치하세요.",
+                "compose/note": "스택은 <code>./system/docker/stacks/&lt;name&gt;/docker-compose.yml</code>에 저장됩니다. 자동 시작이 활성화된 스택은 서버 부팅 시 시작됩니다.",
+                "compose/none": "아직 스택이 없습니다. \"새 스택\"을 클릭하여 만드세요.",
+                "compose/stopped": "중지됨",
+                "compose/disabled": "비활성화됨",
+                "compose/enabled": "활성화됨",
+                "compose/enable": "활성화",
+                "compose/disable": "비활성화",
+                "compose/editTitle": "스택 편집: {name}",
+                "compose/nameLabel": "스택 이름 (영문자, 숫자, 하이픈, 밑줄)",
+                "compose/validate": "검증",
+                "compose/saveDeploy": "저장 후 배포",
+                "compose/nameRequired": "스택 이름은 필수입니다.",
+                "compose/nameRequiredValidate": "검증하려면 스택 이름이 필요합니다.",
+                "compose/saving": "검증 및 저장 중…",
+                "compose/deploying": "배포 중… 이미지를 가져오는 데 시간이 걸릴 수 있습니다.",
+                "compose/deployingStack": "스택 배포 중… 시간이 걸릴 수 있습니다.",
+                "compose/deployFailed": "배포 실패: {error}",
+                "compose/deployed": "스택이 배포되었습니다.",
+                "compose/deployRequestFailed": "배포 요청에 실패했습니다.",
+                "compose/saved": "스택이 저장되었습니다.",
+                "compose/saveRequestFailed": "저장 요청에 실패했습니다.",
+                "compose/valid": "Compose 파일이 유효하며 저장되었습니다.",
+                "compose/validateRequestFailed": "검증 요청에 실패했습니다.",
+                "compose/loadStackFailed": "스택을 불러오지 못했습니다.",
+                "compose/logTitle": "스택: {name}",
+                "compose/toggleFailed": "전환에 실패했습니다.",
+                "compose/confirmDelete": "스택 \"{name}\"을(를) 삭제하시겠습니까? 컨테이너가 중지되고 스택 파일이 삭제됩니다. 되돌릴 수 없습니다.",
+                "compose/deleteFailed": "삭제 실패: {error}",
+                "compose/deleted": "스택이 삭제되었습니다.",
+                "compose/deleteRequestFailed": "삭제 요청에 실패했습니다.",
+                "compose/confirmDown": "스택 \"{name}\"을(를) 중지하시겠습니까? 실행 중인 컨테이너가 제거됩니다 (스택 파일은 유지됩니다).",
+                "registry/loginTitle": "로그인",
+                "registry/registry": "레지스트리 (비워 두면 Docker Hub)",
+                "registry/username": "사용자 이름",
+                "registry/password": "비밀번호 / 토큰",
+                "registry/login": "로그인",
+                "registry/credNote": "자격 증명은 ArozOS가 아닌 Docker CLI가 저장합니다. 비밀번호는 한 번만 전송되며 기록되지 않습니다.",
+                "registry/configured": "구성된 레지스트리",
+                "registry/searchTitle": "Docker Hub 검색",
+                "registry/search": "검색",
+                "registry/searchHint": "검색어를 입력하고 검색하세요.",
+                "registry/none": "로그인된 레지스트리가 없습니다.",
+                "registry/logout": "로그아웃",
+                "registry/credRequired": "사용자 이름과 비밀번호가 필요합니다.",
+                "registry/loginFailed": "로그인 실패: {error}",
+                "registry/loggedIn": "로그인되었습니다.",
+                "registry/loginRequestFailed": "로그인 요청에 실패했습니다.",
+                "registry/logoutFailed": "로그아웃 실패: {error}",
+                "registry/loggedOut": "로그아웃되었습니다.",
+                "registry/logoutRequestFailed": "로그아웃 요청에 실패했습니다.",
+                "registry/searching": "검색 중…",
+                "registry/noResults": "결과가 없습니다.",
+                "registry/official": "공식",
+                "registry/searchFailed": "검색 실패 (관리자 전용).",
+                "drawer/container": "컨테이너:",
+                "drawer/logs": "로그:",
+                "drawer/pulling": "가져오는 중:",
+                "drawer/configure": "구성:",
+                "drawer/console": "콘솔:",
+                "run/title": "이미지 실행",
+                "run/recreateTitle": "컨테이너 재생성",
+                "run/recreateWarn": "<b>재생성</b>하면 기존 컨테이너를 삭제하고 이 설정으로 새 컨테이너를 실행합니다. 되돌릴 수 없습니다.",
+                "run/image": "이미지",
+                "run/name": "컨테이너 이름 (선택)",
+                "run/ports": "포트 매핑 (한 줄에 하나씩)",
+                "run/volumes": "볼륨 (src:dst[:mode])",
+                "run/env": "환경 변수 (한 줄에 하나씩 KEY=value)",
+                "run/restart": "재시작 정책",
+                "run/network": "네트워크 (선택)",
+                "run/command": "명령 재정의 (선택)",
+                "run/createOnly": "생성 (시작 안 함)",
+                "run/run": "실행",
+                "run/recreate": "재생성",
+                "run/imageRequired": "이미지는 필수입니다.",
+                "run/recreated": "컨테이너가 재생성되었습니다.",
+                "run/created": "컨테이너가 생성되었습니다.",
+                "run/started": "컨테이너가 시작되었습니다.",
+                "cfg/rename": "이름 변경",
+                "cfg/liveUpdate": "실시간 업데이트",
+                "cfg/liveUpdateNote": "(재생성 없이 적용)",
+                "cfg/unchanged": "(변경 안 함)",
+                "cfg/memory": "메모리 제한",
+                "cfg/cpus": "CPU 수",
+                "cfg/recreateNote": "포트, 볼륨 또는 환경 변수를 변경하려면 <b>재생성</b>이 필요합니다.",
+                "cfg/recreateLink": "새 설정으로 재생성…",
+                "cfg/apply": "실시간 업데이트 적용",
+                "cfg/enterName": "새 이름을 입력하세요.",
+                "cfg/renameFailed": "이름 변경 실패: {error}",
+                "cfg/renamed": "이름이 변경되었습니다.",
+                "cfg/renameRequestFailed": "이름 변경 요청에 실패했습니다.",
+                "cfg/nothing": "업데이트할 항목이 없습니다.",
+                "cfg/updateFailed": "업데이트 실패: {error}",
+                "cfg/updated": "업데이트되었습니다.",
+                "cfg/updateRequestFailed": "업데이트 요청에 실패했습니다.",
+                "console/warn": "<b>이 컨테이너 내부의 라이브 셸</b>에 연결되어 있습니다. 명령은 컨테이너 권한으로 실행되며 호스트에 영향을 줄 수 있습니다.",
+                "console/shell": "셸:",
+                "console/reconnect": "다시 연결",
+                "console/closed": "세션이 종료됨",
+                "console/connError": "콘솔 연결 오류."
+            },
+            "titles": {
+                "Open in window": "창에서 열기"
+            },
+            "placeholder": {
+                "nginx:alpine or registry/repo:tag": "nginx:alpine 또는 registry/repo:tag",
+                "e.g. nginx": "예: nginx"
+            }
+        }
+    }
+}

+ 19 - 1
src/web/DockerManager/script/dockermanager.js

@@ -2,7 +2,8 @@
     dockermanager.js
 
     Shared helpers for the Docker Manager web app: API base, fetch wrappers,
-    websocket URL resolution (proxy-path safe), HTML escaping, theme and toast.
+    websocket URL resolution (proxy-path safe), HTML escaping, theme, toast and
+    localization (strings live in ../locale/*.json next to the app).
     All paths are relative so the app keeps working when ArozOS is reverse
     proxied under a sub-path.
 */
@@ -49,6 +50,23 @@ var DM = (function () {
             } catch (e) {}
         },
 
+        // Localized string from the page's locale file (English fallback),
+        // with {name} placeholders filled in from vars.
+        t: function (key, fallback, vars) {
+            var s = (typeof applocale !== "undefined" && applocale) ? applocale.getString(key, fallback) : fallback;
+            if (vars) {
+                Object.keys(vars).forEach(function (k) { s = s.split("{" + k + "}").join(vars[k]); });
+            }
+            return s;
+        },
+
+        // Load the locale file, translate the static markup, then start the page.
+        // The page still starts (in English) when the locale file is unavailable.
+        initLocale: function (file, start) {
+            if (typeof applocale === "undefined" || !applocale) { start(); return; }
+            applocale.init(file, function () { applocale.translate(); start(); }, start);
+        },
+
         toast: function (msg, ok) {
             var t = document.getElementById("dm-toast");
             if (!t) return;

+ 9 - 1
src/web/script/applocale.js

@@ -12,6 +12,8 @@
         applocale.init("../locale/file_explorer.json", function(){
             applocale.translate();
             //Do other init things on the page
+        }, function(){
+            //Optional: called instead when the locale file cannot be loaded
         });
     }
 */
@@ -21,12 +23,18 @@ function NewAppLocale(){
         lang: (localStorage.getItem('global_language') == null || localStorage.getItem('global_language') == "default" ? navigator.language : localStorage.getItem('global_language')).toLowerCase(),
         localeFile: "",
         localData: {},
-        init: function(localeFile, callback = undefined) {
+        init: function(localeFile, callback = undefined, onError = undefined) {
             this.localeFile = localeFile;
             let targetApplocaleObject = this;
             $.ajax({
                 dataType: "json",
                 url: localeFile,
+                error: function() {
+                    //Locale file missing or invalid. Let the page start with its built-in strings
+                    if (onError != undefined) {
+                        onError();
+                    }
+                },
                 success: function(data) {
                     targetApplocaleObject.localData = data;
                     if (callback != undefined) {