소스 검색

feat: support i18n with desc struct.

Zhongwen Deng 3 년 전
부모
커밋
630cc8ee34

+ 1 - 0
Makefile

@@ -218,6 +218,7 @@ $(foreach zt,$(ALL_DOCKERS),$(eval $(call gen-docker-target,$(zt))))
 .PHONY:
 conf-segs:
 	@scripts/merge-config.escript
+	@scripts/merge-i18n.escript
 
 ## elixir target is to create release packages using Elixir's Mix
 .PHONY: $(REL_PROFILES:%=%-elixir) $(PKG_PROFILES:%=%-elixir)

+ 1 - 1
apps/emqx/rebar.config

@@ -30,7 +30,7 @@
     {esockd, {git, "https://github.com/emqx/esockd", {tag, "5.9.1"}}},
     {ekka, {git, "https://github.com/emqx/ekka", {tag, "0.12.3"}}},
     {gen_rpc, {git, "https://github.com/emqx/gen_rpc", {tag, "2.8.1"}}},
-    {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.6"}}},
+    {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.7"}}},
     {pbkdf2, {git, "https://github.com/emqx/erlang-pbkdf2.git", {tag, "2.0.4"}}},
     {recon, {git, "https://github.com/ferd/recon", {tag, "2.5.1"}}},
     {snabbkaffe, {git, "https://github.com/kafka4beam/snabbkaffe.git", {tag, "0.18.0"}}}

+ 208 - 93
apps/emqx_conf/src/emqx_conf.erl

@@ -80,15 +80,22 @@ get_node_and_config(KeyPath) ->
     {node(), emqx:get_config(KeyPath, config_not_found)}.
 
 %% @doc Update all value of key path in cluster-override.conf or local-override.conf.
--spec update(emqx_map_lib:config_key_path(), emqx_config:update_request(),
-    emqx_config:update_opts()) ->
+-spec update(
+    emqx_map_lib:config_key_path(),
+    emqx_config:update_request(),
+    emqx_config:update_opts()
+) ->
     {ok, emqx_config:update_result()} | {error, emqx_config:update_error()}.
 update(KeyPath, UpdateReq, Opts) ->
     check_cluster_rpc_result(emqx_conf_proto_v1:update(KeyPath, UpdateReq, Opts)).
 
 %% @doc Update the specified node's key path in local-override.conf.
--spec update(node(), emqx_map_lib:config_key_path(), emqx_config:update_request(),
-    emqx_config:update_opts()) ->
+-spec update(
+    node(),
+    emqx_map_lib:config_key_path(),
+    emqx_config:update_request(),
+    emqx_config:update_opts()
+) ->
     {ok, emqx_config:update_result()} | {error, emqx_config:update_error()} | emqx_rpc:badrpc().
 update(Node, KeyPath, UpdateReq, Opts0) when Node =:= node() ->
     emqx:update_config(KeyPath, UpdateReq, Opts0#{override_to => local});
@@ -129,22 +136,38 @@ dump_schema(Dir) ->
     dump_schema(Dir, emqx_conf_schema).
 
 dump_schema(Dir, SchemaModule) ->
-    SchemaMdFile = filename:join([Dir, "config.md"]),
-    io:format(user, "===< Generating: ~s~n", [SchemaMdFile ]),
-    ok = gen_doc(SchemaMdFile, SchemaModule),
-
-    %% for scripts/spellcheck.
+    PrivDir = filename:dirname(filename:dirname(Dir)),
+    lists:foreach(
+        fun(Lang) ->
+            gen_config_md(Dir, PrivDir, SchemaModule, Lang),
+            gen_hot_conf_schema_json(Dir, PrivDir, Lang)
+        end,
+        [en, zh]
+    ),
+    gen_schema_json(Dir, PrivDir, SchemaModule).
+
+%% for scripts/spellcheck.
+gen_schema_json(Dir, PrivDir, SchemaModule) ->
     SchemaJsonFile = filename:join([Dir, "schema.json"]),
     io:format(user, "===< Generating: ~s~n", [SchemaJsonFile]),
-    JsonMap = hocon_schema_json:gen(SchemaModule),
+    Opts = #{desc_file => i18n_file(PrivDir), lang => "en"},
+    JsonMap = hocon_schema_json:gen(SchemaModule, Opts),
     IoData = jsx:encode(JsonMap, [space, {indent, 4}]),
-    ok = file:write_file(SchemaJsonFile, IoData),
+    ok = file:write_file(SchemaJsonFile, IoData).
 
-    %% hot-update configuration schema
-    HotConfigSchemaFile = filename:join([Dir, "hot-config-schema.json"]),
+gen_hot_conf_schema_json(Dir, PrivDir, Lang) ->
+    emqx_dashboard:init_i18n(i18n_file(PrivDir), Lang),
+    JsonFile = "hot-config-schema-" ++ atom_to_list(Lang) ++ ".json",
+    HotConfigSchemaFile = filename:join([Dir, JsonFile]),
     io:format(user, "===< Generating: ~s~n", [HotConfigSchemaFile]),
     ok = gen_hot_conf_schema(HotConfigSchemaFile),
-    ok.
+    emqx_dashboard:clear_i18n().
+
+gen_config_md(Dir, PrivDir, SchemaModule, Lang0) ->
+    Lang = atom_to_list(Lang0),
+    SchemaMdFile = filename:join([Dir, "config-" ++ Lang ++ ".md"]),
+    io:format(user, "===< Generating: ~s~n", [SchemaMdFile]),
+    ok = gen_doc(SchemaMdFile, SchemaModule, PrivDir, Lang).
 
 %% @doc return the root schema module.
 -spec schema_module() -> module().
@@ -158,63 +181,97 @@ schema_module() ->
 %% Internal functions
 %%--------------------------------------------------------------------
 
--spec gen_doc(file:name_all(), module()) -> ok.
-gen_doc(File, SchemaModule) ->
+-spec gen_doc(file:name_all(), module(), file:name_all(), string()) -> ok.
+gen_doc(File, SchemaModule, EtcDir, Lang) ->
     Version = emqx_release:version(),
     Title = "# " ++ emqx_release:description() ++ " " ++ Version ++ " Configuration",
     BodyFile = filename:join([code:lib_dir(emqx_conf), "etc", "emqx_conf.md"]),
     {ok, Body} = file:read_file(BodyFile),
-    Doc = hocon_schema_md:gen(SchemaModule, #{title => Title, body => Body}),
+    DescFile = i18n_file(EtcDir),
+    Opts = #{title => Title, body => Body, desc_file => DescFile, lang => Lang},
+    Doc = hocon_schema_md:gen(SchemaModule, Opts),
     file:write_file(File, Doc).
 
 check_cluster_rpc_result(Result) ->
     case Result of
-        {ok, _TnxId, Res} -> Res;
+        {ok, _TnxId, Res} ->
+            Res;
         {retry, TnxId, Res, Nodes} ->
             %% The init MFA return ok, but other nodes failed.
             %% We return ok and alert an alarm.
-            ?SLOG(error, #{msg => "failed_to_update_config_in_cluster", nodes => Nodes,
-                           tnx_id => TnxId}),
+            ?SLOG(error, #{
+                msg => "failed_to_update_config_in_cluster",
+                nodes => Nodes,
+                tnx_id => TnxId
+            }),
             Res;
-        {error, Error} -> %% all MFA return not ok or {ok, term()}.
+        %% all MFA return not ok or {ok, term()}.
+        {error, Error} ->
             Error
     end.
 
 %% Only gen hot_conf schema, not all configuration fields.
 gen_hot_conf_schema(File) ->
-    {ApiSpec0, Components0} = emqx_dashboard_swagger:spec(emqx_mgmt_api_configs,
-        #{schema_converter => fun hocon_schema_to_spec/2}),
-    ApiSpec = lists:foldl(fun({Path, Spec, _, _}, Acc) ->
-        NewSpec = maps:fold(fun(Method, #{responses := Responses}, SubAcc) ->
-            case Responses of
-                #{<<"200">> :=
-                    #{<<"content">> := #{<<"application/json">> := #{<<"schema">> := Schema}}}} ->
-                    SubAcc#{Method => Schema};
-                _ -> SubAcc
-            end
-                            end, #{}, Spec),
-        Acc#{list_to_atom(Path) => NewSpec} end, #{}, ApiSpec0),
+    {ApiSpec0, Components0} = emqx_dashboard_swagger:spec(
+        emqx_mgmt_api_configs,
+        #{schema_converter => fun hocon_schema_to_spec/2}
+    ),
+    ApiSpec = lists:foldl(
+        fun({Path, Spec, _, _}, Acc) ->
+            NewSpec = maps:fold(
+                fun(Method, #{responses := Responses}, SubAcc) ->
+                    case Responses of
+                        #{
+                            <<"200">> :=
+                                #{
+                                    <<"content">> := #{
+                                        <<"application/json">> := #{<<"schema">> := Schema}
+                                    }
+                                }
+                        } ->
+                            SubAcc#{Method => Schema};
+                        _ ->
+                            SubAcc
+                    end
+                end,
+                #{},
+                Spec
+            ),
+            Acc#{list_to_atom(Path) => NewSpec}
+        end,
+        #{},
+        ApiSpec0
+    ),
     Components = lists:foldl(fun(M, Acc) -> maps:merge(M, Acc) end, #{}, Components0),
-    IoData = jsx:encode(#{
-        info => #{title => <<"EMQX Hot Conf Schema">>, version => <<"0.1.0">>},
-        paths => ApiSpec,
-        components => #{schemas => Components}
-    }, [space, {indent, 4}]),
+    IoData = jsx:encode(
+        #{
+            info => #{title => <<"EMQX Hot Conf Schema">>, version => <<"0.1.0">>},
+            paths => ApiSpec,
+            components => #{schemas => Components}
+        },
+        [space, {indent, 4}]
+    ),
     file:write_file(File, IoData).
 
--define(INIT_SCHEMA, #{fields => #{}, translations => #{},
-    validations => [], namespace => undefined}).
+-define(INIT_SCHEMA, #{
+    fields => #{},
+    translations => #{},
+    validations => [],
+    namespace => undefined
+}).
 
 -define(TO_REF(_N_, _F_), iolist_to_binary([to_bin(_N_), ".", to_bin(_F_)])).
--define(TO_COMPONENTS_SCHEMA(_M_, _F_), iolist_to_binary([<<"#/components/schemas/">>,
-    ?TO_REF(emqx_dashboard_swagger:namespace(_M_), _F_)])).
+-define(TO_COMPONENTS_SCHEMA(_M_, _F_),
+    iolist_to_binary([
+        <<"#/components/schemas/">>,
+        ?TO_REF(emqx_dashboard_swagger:namespace(_M_), _F_)
+    ])
+).
 
 hocon_schema_to_spec(?R_REF(Module, StructName), _LocalModule) ->
-    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)},
-        [{Module, StructName}]};
+    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)}, [{Module, StructName}]};
 hocon_schema_to_spec(?REF(StructName), LocalModule) ->
-    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)},
-        [{LocalModule, StructName}]};
+    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)}, [{LocalModule, StructName}]};
 hocon_schema_to_spec(Type, LocalModule) when ?IS_TYPEREFL(Type) ->
     {typename_to_spec(typerefl:name(Type), LocalModule), []};
 hocon_schema_to_spec(?ARRAY(Item), LocalModule) ->
@@ -226,50 +283,97 @@ hocon_schema_to_spec(?ENUM(Items), _LocalModule) ->
     {#{type => enum, symbols => Items}, []};
 hocon_schema_to_spec(?MAP(Name, Type), LocalModule) ->
     {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
-    {#{<<"type">> => object,
-        <<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema}},
-        SubRefs};
+    {
+        #{
+            <<"type">> => object,
+            <<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema}
+        },
+        SubRefs
+    };
 hocon_schema_to_spec(?UNION(Types), LocalModule) ->
-    {OneOf, Refs} = lists:foldl(fun(Type, {Acc, RefsAcc}) ->
-        {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
-        {[Schema | Acc], SubRefs ++ RefsAcc}
-                                end, {[], []}, Types),
+    {OneOf, Refs} = lists:foldl(
+        fun(Type, {Acc, RefsAcc}) ->
+            {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
+            {[Schema | Acc], SubRefs ++ RefsAcc}
+        end,
+        {[], []},
+        Types
+    ),
     {#{<<"oneOf">> => OneOf}, Refs};
 hocon_schema_to_spec(Atom, _LocalModule) when is_atom(Atom) ->
     {#{type => enum, symbols => [Atom]}, []}.
 
-typename_to_spec("user_id_type()", _Mod) -> #{type => enum, symbols => [clientid, username]};
-typename_to_spec("term()", _Mod) -> #{type => string};
-typename_to_spec("boolean()", _Mod) -> #{type => boolean};
-typename_to_spec("binary()", _Mod) -> #{type => string};
-typename_to_spec("float()", _Mod) -> #{type => number};
-typename_to_spec("integer()", _Mod) -> #{type => number};
-typename_to_spec("non_neg_integer()", _Mod) -> #{type => number, minimum => 1};
-typename_to_spec("number()", _Mod) -> #{type => number};
-typename_to_spec("string()", _Mod) -> #{type => string};
-typename_to_spec("atom()", _Mod) -> #{type => string};
-
-typename_to_spec("duration()", _Mod) -> #{type => duration};
-typename_to_spec("duration_s()", _Mod) -> #{type => duration};
-typename_to_spec("duration_ms()", _Mod) -> #{type => duration};
-typename_to_spec("percent()", _Mod) -> #{type => percent};
-typename_to_spec("file()", _Mod) -> #{type => string};
-typename_to_spec("ip_port()", _Mod) -> #{type => ip_port};
-typename_to_spec("url()", _Mod) -> #{type => url};
-typename_to_spec("bytesize()", _Mod) -> #{type => 'byteSize'};
-typename_to_spec("wordsize()", _Mod) -> #{type => 'byteSize'};
-typename_to_spec("qos()", _Mod) -> #{type => enum, symbols => [0, 1, 2]};
-typename_to_spec("comma_separated_list()", _Mod) -> #{type => comma_separated_string};
-typename_to_spec("comma_separated_atoms()", _Mod) -> #{type => comma_separated_string};
-typename_to_spec("pool_type()", _Mod) -> #{type => enum, symbols => [random, hash]};
+typename_to_spec("user_id_type()", _Mod) ->
+    #{type => enum, symbols => [clientid, username]};
+typename_to_spec("term()", _Mod) ->
+    #{type => string};
+typename_to_spec("boolean()", _Mod) ->
+    #{type => boolean};
+typename_to_spec("binary()", _Mod) ->
+    #{type => string};
+typename_to_spec("float()", _Mod) ->
+    #{type => number};
+typename_to_spec("integer()", _Mod) ->
+    #{type => number};
+typename_to_spec("non_neg_integer()", _Mod) ->
+    #{type => number, minimum => 1};
+typename_to_spec("number()", _Mod) ->
+    #{type => number};
+typename_to_spec("string()", _Mod) ->
+    #{type => string};
+typename_to_spec("atom()", _Mod) ->
+    #{type => string};
+typename_to_spec("duration()", _Mod) ->
+    #{type => duration};
+typename_to_spec("duration_s()", _Mod) ->
+    #{type => duration};
+typename_to_spec("duration_ms()", _Mod) ->
+    #{type => duration};
+typename_to_spec("percent()", _Mod) ->
+    #{type => percent};
+typename_to_spec("file()", _Mod) ->
+    #{type => string};
+typename_to_spec("ip_port()", _Mod) ->
+    #{type => ip_port};
+typename_to_spec("url()", _Mod) ->
+    #{type => url};
+typename_to_spec("bytesize()", _Mod) ->
+    #{type => 'byteSize'};
+typename_to_spec("wordsize()", _Mod) ->
+    #{type => 'byteSize'};
+typename_to_spec("qos()", _Mod) ->
+    #{type => enum, symbols => [0, 1, 2]};
+typename_to_spec("comma_separated_list()", _Mod) ->
+    #{type => comma_separated_string};
+typename_to_spec("comma_separated_atoms()", _Mod) ->
+    #{type => comma_separated_string};
+typename_to_spec("pool_type()", _Mod) ->
+    #{type => enum, symbols => [random, hash]};
 typename_to_spec("log_level()", _Mod) ->
-    #{type => enum, symbols => [debug, info, notice, warning, error,
-        critical, alert, emergency, all]};
-typename_to_spec("rate()", _Mod) -> #{type => string};
-typename_to_spec("capacity()", _Mod) -> #{type => string};
-typename_to_spec("burst_rate()", _Mod) -> #{type => string};
-typename_to_spec("failure_strategy()", _Mod) -> #{type => enum, symbols => [force, drop, throw]};
-typename_to_spec("initial()", _Mod) -> #{type => string};
+    #{
+        type => enum,
+        symbols => [
+            debug,
+            info,
+            notice,
+            warning,
+            error,
+            critical,
+            alert,
+            emergency,
+            all
+        ]
+    };
+typename_to_spec("rate()", _Mod) ->
+    #{type => string};
+typename_to_spec("capacity()", _Mod) ->
+    #{type => string};
+typename_to_spec("burst_rate()", _Mod) ->
+    #{type => string};
+typename_to_spec("failure_strategy()", _Mod) ->
+    #{type => enum, symbols => [force, drop, throw]};
+typename_to_spec("initial()", _Mod) ->
+    #{type => string};
 typename_to_spec(Name, Mod) ->
     Spec = range(Name),
     Spec1 = remote_module_type(Spec, Name, Mod),
@@ -282,11 +386,13 @@ default_type(Type) -> Type.
 
 range(Name) ->
     case string:split(Name, "..") of
-        [MinStr, MaxStr] -> %% 1..10 1..inf -inf..10
+        %% 1..10 1..inf -inf..10
+        [MinStr, MaxStr] ->
             Schema = #{type => number},
             Schema1 = add_integer_prop(Schema, minimum, MinStr),
             add_integer_prop(Schema1, maximum, MaxStr);
-        _ -> nomatch
+        _ ->
+            nomatch
     end.
 
 %% Module:Type
@@ -295,21 +401,25 @@ remote_module_type(nomatch, Name, Mod) ->
         [_Module, Type] -> typename_to_spec(Type, Mod);
         _ -> nomatch
     end;
-remote_module_type(Spec, _Name, _Mod) -> Spec.
+remote_module_type(Spec, _Name, _Mod) ->
+    Spec.
 
 %% [string()] or [integer()] or [xxx].
 typerefl_array(nomatch, Name, Mod) ->
     case string:trim(Name, leading, "[") of
-        Name -> nomatch;
+        Name ->
+            nomatch;
         Name1 ->
             case string:trim(Name1, trailing, "]") of
-                Name1 -> notmatch;
+                Name1 ->
+                    notmatch;
                 Name2 ->
                     Schema = typename_to_spec(Name2, Mod),
                     #{type => array, items => Schema}
             end
     end;
-typerefl_array(Spec, _Name, _Mod) -> Spec.
+typerefl_array(Spec, _Name, _Mod) ->
+    Spec.
 
 %% integer(1)
 integer(nomatch, Name) ->
@@ -317,12 +427,13 @@ integer(nomatch, Name) ->
         {Int, []} -> #{type => enum, symbols => [Int], default => Int};
         _ -> nomatch
     end;
-integer(Spec, _Name) -> Spec.
+integer(Spec, _Name) ->
+    Spec.
 
 add_integer_prop(Schema, Key, Value) ->
     case string:to_integer(Value) of
         {error, no_integer} -> Schema;
-        {Int, []}when Key =:= minimum -> Schema#{Key => Int};
+        {Int, []} when Key =:= minimum -> Schema#{Key => Int};
         {Int, []} -> Schema#{Key => Int}
     end.
 
@@ -333,4 +444,8 @@ to_bin(List) when is_list(List) ->
     end;
 to_bin(Boolean) when is_boolean(Boolean) -> Boolean;
 to_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
-to_bin(X) -> X.
+to_bin(X) ->
+    X.
+
+i18n_file(EtcDir) ->
+    filename:join([EtcDir, "i18n.conf"]).

+ 12 - 0
apps/emqx_dashboard/etc/emqx_dashboard_i18n.conf

@@ -0,0 +1,12 @@
+emqx_dashboard_schema {
+  protocol {
+    desc {
+      en: "Protocol Name"
+      zh: "协议名"
+    }
+    label: {
+      en: "Protocol"
+      zh: "协议"
+    }
+  }
+}

+ 128 - 69
apps/emqx_dashboard/src/emqx_dashboard.erl

@@ -18,11 +18,18 @@
 
 -define(APP, ?MODULE).
 
+-export([
+    start_listeners/0,
+    start_listeners/1,
+    stop_listeners/1,
+    stop_listeners/0
+]).
 
--export([ start_listeners/0
-        , start_listeners/1
-        , stop_listeners/1
-        , stop_listeners/0]).
+-export([
+    init_i18n/2,
+    get_i18n/0,
+    clear_i18n/0
+]).
 
 %% Authorization
 -export([authorize/1]).
@@ -48,6 +55,7 @@ stop_listeners() ->
 
 start_listeners(Listeners) ->
     {ok, _} = application:ensure_all_started(minirest),
+    init_i18n(),
     Authorization = {?MODULE, authorize},
     GlobalSpec = #{
         openapi => "3.0.0",
@@ -58,12 +66,15 @@ start_listeners(Listeners) ->
             'securitySchemes' => #{
                 'basicAuth' => #{type => http, scheme => basic},
                 'bearerAuth' => #{type => http, scheme => bearer}
-            }}},
-    Dispatch = [ {"/", cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}}
-               , {"/static/[...]", cowboy_static, {priv_dir, emqx_dashboard, "www/static"}}
-               , {?BASE_PATH ++ "/[...]", emqx_dashboard_bad_api, []}
-               , {'_', cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}}
-               ],
+            }
+        }
+    },
+    Dispatch = [
+        {"/", cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}},
+        {"/static/[...]", cowboy_static, {priv_dir, emqx_dashboard, "www/static"}},
+        {?BASE_PATH ++ "/[...]", emqx_dashboard_bad_api, []},
+        {'_', cowboy_static, {priv_file, emqx_dashboard, "www/index.html"}}
+    ],
     BaseMinirest = #{
         base_path => ?BASE_PATH,
         modules => minirest_api:find_api_modules(apps()),
@@ -74,75 +85,115 @@ start_listeners(Listeners) ->
         middlewares => [cowboy_router, ?EMQX_MIDDLE, cowboy_handler]
     },
     Res =
-        lists:foldl(fun({Name, Protocol, Bind, RanchOptions}, Acc) ->
-            Minirest = BaseMinirest#{protocol => Protocol},
-            case minirest:start(Name, RanchOptions, Minirest) of
-                {ok, _} ->
-                    ?ULOG("Start listener ~ts on ~ts successfully.~n", [Name, emqx_listeners:format_addr(Bind)]),
-                    Acc;
-                {error, _Reason} ->
-                    %% Don't record the reason because minirest already does(too much logs noise).
-                    [Name | Acc]
-            end
-                    end, [], listeners(Listeners)),
+        lists:foldl(
+            fun({Name, Protocol, Bind, RanchOptions}, Acc) ->
+                Minirest = BaseMinirest#{protocol => Protocol},
+                case minirest:start(Name, RanchOptions, Minirest) of
+                    {ok, _} ->
+                        ?ULOG("Start listener ~ts on ~ts successfully.~n", [
+                            Name, emqx_listeners:format_addr(Bind)
+                        ]),
+                        Acc;
+                    {error, _Reason} ->
+                        %% Don't record the reason because minirest already does(too much logs noise).
+                        [Name | Acc]
+                end
+            end,
+            [],
+            listeners(Listeners)
+        ),
+    clear_i18n(),
     case Res of
         [] -> ok;
         _ -> {error, Res}
     end.
 
 stop_listeners(Listeners) ->
-    [begin
-        case minirest:stop(Name) of
-            ok ->
-                ?ULOG("Stop listener ~ts on ~ts successfully.~n", [Name, emqx_listeners:format_addr(Port)]);
-            {error, not_found} ->
-                ?SLOG(warning, #{msg => "stop_listener_failed", name => Name, port => Port})
+    [
+        begin
+            case minirest:stop(Name) of
+                ok ->
+                    ?ULOG("Stop listener ~ts on ~ts successfully.~n", [
+                        Name, emqx_listeners:format_addr(Port)
+                    ]);
+                {error, not_found} ->
+                    ?SLOG(warning, #{msg => "stop_listener_failed", name => Name, port => Port})
+            end
         end
-     end || {Name, _, Port, _} <- listeners(Listeners)],
+     || {Name, _, Port, _} <- listeners(Listeners)
+    ],
     ok.
 
+get_i18n() ->
+    application:get_env(emqx_dashboard, i18n).
+
+init_i18n(File, Lang) ->
+    Cache = hocon_schema:new_cache(File),
+    application:set_env(emqx_dashboard, i18n, #{lang => atom_to_binary(Lang), cache => Cache}).
+
+clear_i18n() ->
+    case application:get_env(emqx_dashboard, i18n) of
+        {ok, #{cache := Cache}} ->
+            hocon_schema:delete_cache(Cache),
+            application:unset_env(emqx_dashboard, i18n);
+        undefined ->
+            ok
+    end.
+
 %%--------------------------------------------------------------------
 %% internal
 
 apps() ->
-    [App || {App, _, _} <- application:loaded_applications(),
+    [
+        App
+     || {App, _, _} <- application:loaded_applications(),
         case re:run(atom_to_list(App), "^emqx") of
-            {match,[{0,4}]} -> true;
+            {match, [{0, 4}]} -> true;
             _ -> false
-        end].
+        end
+    ].
 
 listeners(Listeners) ->
-    [begin
-        Protocol = maps:get(protocol, ListenerOption0, http),
-        {ListenerOption, Bind} = ip_port(ListenerOption0),
-        Name = listener_name(Protocol, ListenerOption),
-        RanchOptions = ranch_opts(maps:without([protocol], ListenerOption)),
-        {Name, Protocol, Bind, RanchOptions}
-    end || ListenerOption0 <- Listeners].
+    [
+        begin
+            Protocol = maps:get(protocol, ListenerOption0, http),
+            {ListenerOption, Bind} = ip_port(ListenerOption0),
+            Name = listener_name(Protocol, ListenerOption),
+            RanchOptions = ranch_opts(maps:without([protocol], ListenerOption)),
+            {Name, Protocol, Bind, RanchOptions}
+        end
+     || ListenerOption0 <- Listeners
+    ].
 
 ip_port(Opts) -> ip_port(maps:take(bind, Opts), Opts).
 
-ip_port(error, Opts)  -> {Opts#{port => 18083}, 18083};
+ip_port(error, Opts) -> {Opts#{port => 18083}, 18083};
 ip_port({Port, Opts}, _) when is_integer(Port) -> {Opts#{port => Port}, Port};
 ip_port({{IP, Port}, Opts}, _) -> {Opts#{port => Port, ip => IP}, {IP, Port}}.
 
+init_i18n() ->
+    File = emqx:etc_file("i18n.conf"),
+    Lang = emqx_conf:get([dashboard, i18n_lang]),
+    init_i18n(File, Lang).
 
 ranch_opts(RanchOptions) ->
-    Keys = [ {ack_timeout, handshake_timeout}
-            , connection_type
-            , max_connections
-            , num_acceptors
-            , shutdown
-            , socket],
+    Keys = [
+        {ack_timeout, handshake_timeout},
+        connection_type,
+        max_connections,
+        num_acceptors,
+        shutdown,
+        socket
+    ],
     {S, R} = lists:foldl(fun key_take/2, {RanchOptions, #{}}, Keys),
     R#{socket_opts => maps:fold(fun key_only/3, [], S)}.
 
-
-key_take(Key, {All, R})  ->
-    {K, KX} = case Key of
-                  {K1, K2} -> {K1, K2};
-                  _ -> {Key, Key}
-              end,
+key_take(Key, {All, R}) ->
+    {K, KX} =
+        case Key of
+            {K1, K2} -> {K1, K2};
+            _ -> {Key, Key}
+        end,
     case maps:get(K, All, undefined) of
         undefined ->
             {All, R};
@@ -150,20 +201,22 @@ key_take(Key, {All, R})  ->
             {maps:remove(K, All), R#{KX => V}}
     end.
 
-key_only(K , true , S)  -> [K | S];
-key_only(_K, false, S)  -> S;
-key_only(K , V    , S)  -> [{K, V} | S].
+key_only(K, true, S) -> [K | S];
+key_only(_K, false, S) -> S;
+key_only(K, V, S) -> [{K, V} | S].
 
 listener_name(Protocol, #{port := Port, ip := IP}) ->
-    Name = "dashboard:"
-        ++ atom_to_list(Protocol) ++ ":"
-        ++ inet:ntoa(IP) ++ ":"
-        ++ integer_to_list(Port),
+    Name =
+        "dashboard:" ++
+            atom_to_list(Protocol) ++ ":" ++
+            inet:ntoa(IP) ++ ":" ++
+            integer_to_list(Port),
     list_to_atom(Name);
 listener_name(Protocol, #{port := Port}) ->
-    Name = "dashboard:"
-        ++ atom_to_list(Protocol) ++ ":"
-        ++ integer_to_list(Port),
+    Name =
+        "dashboard:" ++
+            atom_to_list(Protocol) ++ ":" ++
+            integer_to_list(Port),
     list_to_atom(Name).
 
 authorize(Req) ->
@@ -180,11 +233,13 @@ authorize(Req) ->
                         {error, <<"not_allowed">>} ->
                             return_unauthorized(
                                 ?WRONG_USERNAME_OR_PWD,
-                                <<"Check username/password">>);
+                                <<"Check username/password">>
+                            );
                         {error, _} ->
                             return_unauthorized(
                                 ?WRONG_USERNAME_OR_PWD_OR_API_KEY_OR_API_SECRET,
-                                <<"Check username/password or api_key/api_secret">>)
+                                <<"Check username/password or api_key/api_secret">>
+                            )
                     end;
                 {error, _} ->
                     return_unauthorized(<<"WORNG_USERNAME_OR_PWD">>, <<"Check username/password">>)
@@ -199,12 +254,16 @@ authorize(Req) ->
                     {401, 'BAD_TOKEN', <<"Get a token by POST /login">>}
             end;
         _ ->
-            return_unauthorized(<<"AUTHORIZATION_HEADER_ERROR">>,
-                <<"Support authorization: basic/bearer ">>)
+            return_unauthorized(
+                <<"AUTHORIZATION_HEADER_ERROR">>,
+                <<"Support authorization: basic/bearer ">>
+            )
     end.
 
 return_unauthorized(Code, Message) ->
-    {401, #{<<"WWW-Authenticate">> =>
-    <<"Basic Realm=\"minirest-server\"">>},
-        #{code => Code, message => Message}
-    }.
+    {401,
+        #{
+            <<"WWW-Authenticate">> =>
+                <<"Basic Realm=\"minirest-server\"">>
+        },
+        #{code => Code, message => Message}}.

+ 148 - 86
apps/emqx_dashboard/src/emqx_dashboard_schema.erl

@@ -15,87 +15,130 @@
 %%--------------------------------------------------------------------
 -module(emqx_dashboard_schema).
 
--include_lib("typerefl/include/types.hrl").
+-include_lib("hocon/include/hoconsc.hrl").
 
--export([ roots/0
-        , fields/1
-        , namespace/0
-        , desc/1
-        ]).
+-export([
+    roots/0,
+    fields/1,
+    namespace/0,
+    desc/1
+]).
 
 namespace() -> <<"dashboard">>.
 roots() -> ["dashboard"].
 
 fields("dashboard") ->
-    [ {listeners,
-        sc(hoconsc:array(hoconsc:union([hoconsc:ref(?MODULE, "http"),
-            hoconsc:ref(?MODULE, "https")])),
-            #{ desc =>
-"HTTP(s) listeners are identified by their protocol type and are
-used to serve dashboard UI and restful HTTP API.<br>
-Listeners must have a unique combination of port number and IP address.<br>
-For example, an HTTP listener can listen on all configured IP addresses
-on a given port for a machine by specifying the IP address 0.0.0.0.<br>
-Alternatively, the HTTP listener can specify a unique IP address for each listener,
-but use the same port."})}
-    , {default_username, fun default_username/1}
-    , {default_password, fun default_password/1}
-    , {sample_interval, sc(emqx_schema:duration_s(),
-                           #{ default => "10s"
-                            , desc => "How often to update metrics displayed in the dashboard.<br/>"
-                                      "Note: `sample_interval` should be a divisor of 60."
-                            })}
-    , {token_expired_time, sc(emqx_schema:duration(),
-                              #{ default => "30m"
-                               , desc => "JWT token expiration time."
-                               })}
-    , {cors, fun cors/1}
+    [
+        {listeners,
+            sc(
+                hoconsc:array(
+                    hoconsc:union([
+                        hoconsc:ref(?MODULE, "http"),
+                        hoconsc:ref(?MODULE, "https")
+                    ])
+                ),
+                #{
+                    desc =>
+                        "HTTP(s) listeners are identified by their protocol type and are\n"
+                        "used to serve dashboard UI and restful HTTP API.<br>\n"
+                        "Listeners must have a unique combination of port number and IP address.<br>\n"
+                        "For example, an HTTP listener can listen on all configured IP addresses\n"
+                        "on a given port for a machine by specifying the IP address 0.0.0.0.<br>\n"
+                        "Alternatively, the HTTP listener can specify a unique IP address for each listener,\n"
+                        "but use the same port."
+                }
+            )},
+        {default_username, fun default_username/1},
+        {default_password, fun default_password/1},
+        {sample_interval,
+            sc(
+                emqx_schema:duration_s(),
+                #{
+                    default => "10s",
+                    desc =>
+                        "How often to update metrics displayed in the dashboard.<br/>"
+                        "Note: `sample_interval` should be a divisor of 60."
+                }
+            )},
+        {token_expired_time,
+            sc(
+                emqx_schema:duration(),
+                #{
+                    default => "30m",
+                    desc => "JWT token expiration time."
+                }
+            )},
+        {cors, fun cors/1},
+        {i18n_lang, fun i18n_lang/1}
     ];
-
 fields("http") ->
-    [ {"protocol", sc(
-        hoconsc:enum([http, https]),
-        #{ desc => "HTTP/HTTPS protocol."
-         , required => true
-         , default => http
-         })}
-    , {"bind", fun bind/1}
-    , {"num_acceptors", sc(
-        integer(),
-        #{ default => 4
-         , desc => "Socket acceptor pool size for TCP protocols."
-         })}
-    , {"max_connections",
-       sc(integer(),
-          #{ default => 512
-           , desc => "Maximum number of simultaneous connections."
-           })}
-    , {"backlog",
-       sc(integer(),
-          #{ default => 1024
-           , desc => "Defines the maximum length that the queue of pending connections can grow to."
-           })}
-    , {"send_timeout",
-       sc(emqx_schema:duration(),
-          #{ default => "5s"
-           , desc => "Send timeout for the socket."
-           })}
-    , {"inet6",
-       sc(boolean(),
-          #{ default => false
-           , desc => "Sets up the listener for IPv6."
-           })}
-    , {"ipv6_v6only",
-       sc(boolean(),
-          #{ default => false
-           , desc => "Disable IPv4-to-IPv6 mapping for the listener."
-           })}
+    [
+        {"protocol",
+            sc(
+                hoconsc:enum([http, https]),
+                #{
+                    desc => ?DESC("protocol"),
+                    required => true,
+                    default => http
+                }
+            )},
+        {"bind", fun bind/1},
+        {"num_acceptors",
+            sc(
+                integer(),
+                #{
+                    default => 4,
+                    desc => "Socket acceptor pool size for TCP protocols."
+                }
+            )},
+        {"max_connections",
+            sc(
+                integer(),
+                #{
+                    default => 512,
+                    desc => "Maximum number of simultaneous connections."
+                }
+            )},
+        {"backlog",
+            sc(
+                integer(),
+                #{
+                    default => 1024,
+                    desc =>
+                        "Defines the maximum length that the queue of pending connections can grow to."
+                }
+            )},
+        {"send_timeout",
+            sc(
+                emqx_schema:duration(),
+                #{
+                    default => "5s",
+                    desc => "Send timeout for the socket."
+                }
+            )},
+        {"inet6",
+            sc(
+                boolean(),
+                #{
+                    default => false,
+                    desc => "Sets up the listener for IPv6."
+                }
+            )},
+        {"ipv6_v6only",
+            sc(
+                boolean(),
+                #{
+                    default => false,
+                    desc => "Disable IPv4-to-IPv6 mapping for the listener."
+                }
+            )}
     ];
-
 fields("https") ->
     fields("http") ++
-    proplists:delete("fail_if_no_peer_cert",
-                     emqx_schema:server_ssl_opts_schema(#{}, true)).
+        proplists:delete(
+            "fail_if_no_peer_cert",
+            emqx_schema:server_ssl_opts_schema(#{}, true)
+        ).
 
 desc("dashboard") ->
     "Configuration for EMQX dashboard.";
@@ -119,23 +162,42 @@ default_username(desc) -> "The default username of the automatically created das
 default_username('readOnly') -> true;
 default_username(_) -> undefined.
 
-default_password(type) -> string();
-default_password(default) -> "public";
-default_password(required) -> true;
-default_password('readOnly') -> true;
-default_password(sensitive) -> true;
-default_password(desc) -> """
-The initial default password for dashboard 'admin' user.
-For safety, it should be changed as soon as possible.""";
-default_password(_) -> undefined.
+default_password(type) ->
+    string();
+default_password(default) ->
+    "public";
+default_password(required) ->
+    true;
+default_password('readOnly') ->
+    true;
+default_password(sensitive) ->
+    true;
+default_password(desc) ->
+    ""
+    "\n"
+    "The initial default password for dashboard 'admin' user.\n"
+    "For safety, it should be changed as soon as possible."
+    "";
+default_password(_) ->
+    undefined.
 
-cors(type) -> boolean();
-cors(default) -> false;
-cors(required) -> false;
+cors(type) ->
+    boolean();
+cors(default) ->
+    false;
+cors(required) ->
+    false;
 cors(desc) ->
-"Support Cross-Origin Resource Sharing (CORS).
-Allows a server to indicate any origins (domain, scheme, or port) other than
-its own from which a browser should permit loading resources.";
-cors(_) -> undefined.
+    "Support Cross-Origin Resource Sharing (CORS).\n"
+    "Allows a server to indicate any origins (domain, scheme, or port) other than\n"
+    "its own from which a browser should permit loading resources.";
+cors(_) ->
+    undefined.
+
+i18n_lang(type) -> ?ENUM([en, zh]);
+i18n_lang(default) -> zh;
+i18n_lang('readOnly') -> true;
+i18n_lang(desc) -> "i18n language";
+i18n_lang(_) -> undefined.
 
 sc(Type, Meta) -> hoconsc:mk(Type, Meta).

+ 386 - 209
apps/emqx_dashboard/src/emqx_dashboard_swagger.erl

@@ -28,101 +28,136 @@
 -export([filter_check_request/2, filter_check_request_and_translate_body/2]).
 
 -ifdef(TEST).
--export([ parse_spec_ref/3
-        , components/2
-        ]).
+-export([
+    parse_spec_ref/3,
+    components/2
+]).
 -endif.
 
 -define(METHODS, [get, post, put, head, delete, patch, options, trace]).
 
--define(DEFAULT_FIELDS, [example, allowReserved, style, format, readOnly,
-    explode, maxLength, allowEmptyValue, deprecated, minimum, maximum]).
-
--define(INIT_SCHEMA, #{fields => #{}, translations => #{},
-                       validations => [], namespace => undefined}).
+-define(DEFAULT_FIELDS, [
+    example,
+    allowReserved,
+    style,
+    format,
+    readOnly,
+    explode,
+    maxLength,
+    allowEmptyValue,
+    deprecated,
+    minimum,
+    maximum
+]).
+
+-define(INIT_SCHEMA, #{
+    fields => #{},
+    translations => #{},
+    validations => [],
+    namespace => undefined
+}).
 
 -define(TO_REF(_N_, _F_), iolist_to_binary([to_bin(_N_), ".", to_bin(_F_)])).
--define(TO_COMPONENTS_SCHEMA(_M_, _F_), iolist_to_binary([<<"#/components/schemas/">>,
-                                                         ?TO_REF(namespace(_M_), _F_)])).
--define(TO_COMPONENTS_PARAM(_M_, _F_), iolist_to_binary([<<"#/components/parameters/">>,
-                                                         ?TO_REF(namespace(_M_), _F_)])).
+-define(TO_COMPONENTS_SCHEMA(_M_, _F_),
+    iolist_to_binary([
+        <<"#/components/schemas/">>,
+        ?TO_REF(namespace(_M_), _F_)
+    ])
+).
+-define(TO_COMPONENTS_PARAM(_M_, _F_),
+    iolist_to_binary([
+        <<"#/components/parameters/">>,
+        ?TO_REF(namespace(_M_), _F_)
+    ])
+).
 
 -define(MAX_ROW_LIMIT, 1000).
 -define(DEFAULT_ROW, 100).
 
--type(request() :: #{bindings => map(), query_string => map(), body => map()}).
--type(request_meta() :: #{module => module(), path => string(), method => atom()}).
+-type request() :: #{bindings => map(), query_string => map(), body => map()}.
+-type request_meta() :: #{module => module(), path => string(), method => atom()}.
 
--type(filter_result() :: {ok, request()} | {400, 'BAD_REQUEST', binary()}).
--type(filter() :: fun((request(), request_meta()) -> filter_result())).
+-type filter_result() :: {ok, request()} | {400, 'BAD_REQUEST', binary()}.
+-type filter() :: fun((request(), request_meta()) -> filter_result()).
 
--type(spec_opts() :: #{check_schema => boolean() | filter(),
-                       translate_body => boolean(),
-                       schema_converter => fun((hocon_schema:schema(), Module::atom()) -> map())
-                       }).
+-type spec_opts() :: #{
+    check_schema => boolean() | filter(),
+    translate_body => boolean(),
+    schema_converter => fun((hocon_schema:schema(), Module :: atom()) -> map())
+}.
 
--type(route_path() :: string() | binary()).
--type(route_methods() :: map()).
--type(route_handler() :: atom()).
--type(route_options() :: #{filter => filter() | undefined}).
+-type route_path() :: string() | binary().
+-type route_methods() :: map().
+-type route_handler() :: atom().
+-type route_options() :: #{filter => filter() | undefined}.
 
--type(api_spec_entry() :: {route_path(), route_methods(), route_handler(), route_options()}).
--type(api_spec_component() :: map()).
+-type api_spec_entry() :: {route_path(), route_methods(), route_handler(), route_options()}.
+-type api_spec_component() :: map().
 
 %%------------------------------------------------------------------------------
 %% API
 %%------------------------------------------------------------------------------
 
 %% @equiv spec(Module, #{check_schema => false})
--spec(spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}).
+-spec spec(module()) -> {list(api_spec_entry()), list(api_spec_component())}.
 spec(Module) -> spec(Module, #{check_schema => false}).
 
--spec(spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}).
+-spec spec(module(), spec_opts()) -> {list(api_spec_entry()), list(api_spec_component())}.
 spec(Module, Options) ->
     Paths = apply(Module, paths, []),
     {ApiSpec, AllRefs} =
-        lists:foldl(fun(Path, {AllAcc, AllRefsAcc}) ->
-            {OperationId, Specs, Refs} = parse_spec_ref(Module, Path, Options),
-            CheckSchema = support_check_schema(Options),
-            {[{filename:join("/", Path), Specs, OperationId, CheckSchema} | AllAcc],
-                    Refs ++ AllRefsAcc}
-                    end, {[], []}, Paths),
+        lists:foldl(
+            fun(Path, {AllAcc, AllRefsAcc}) ->
+                {OperationId, Specs, Refs} = parse_spec_ref(Module, Path, Options),
+                CheckSchema = support_check_schema(Options),
+                {
+                    [{filename:join("/", Path), Specs, OperationId, CheckSchema} | AllAcc],
+                    Refs ++ AllRefsAcc
+                }
+            end,
+            {[], []},
+            Paths
+        ),
     {ApiSpec, components(lists:usort(AllRefs), Options)}.
 
--spec(namespace() -> hocon_schema:name()).
+-spec namespace() -> hocon_schema:name().
 namespace() -> "public".
 
--spec(fields(hocon_schema:name()) -> hocon_schema:fields()).
+-spec fields(hocon_schema:name()) -> hocon_schema:fields().
 fields(page) ->
     Desc = <<"Page number of the results to fetch.">>,
     Meta = #{in => query, desc => Desc, default => 1, example => 1},
     [{page, hoconsc:mk(integer(), Meta)}];
 fields(limit) ->
-    Desc = iolist_to_binary([<<"Results per page(max ">>,
-        integer_to_binary(?MAX_ROW_LIMIT), <<")">>]),
+    Desc = iolist_to_binary([
+        <<"Results per page(max ">>,
+        integer_to_binary(?MAX_ROW_LIMIT),
+        <<")">>
+    ]),
     Meta = #{in => query, desc => Desc, default => ?DEFAULT_ROW, example => 50},
     [{limit, hoconsc:mk(range(1, ?MAX_ROW_LIMIT), Meta)}].
 
--spec(schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema_map()).
+-spec schema_with_example(hocon_schema:type(), term()) -> hocon_schema:field_schema_map().
 schema_with_example(Type, Example) ->
     hoconsc:mk(Type, #{examples => #{<<"example">> => Example}}).
 
--spec(schema_with_examples(hocon_schema:type(), map()) -> hocon_schema:field_schema_map()).
+-spec schema_with_examples(hocon_schema:type(), map()) -> hocon_schema:field_schema_map().
 schema_with_examples(Type, Examples) ->
     hoconsc:mk(Type, #{examples => #{<<"examples">> => Examples}}).
 
--spec(error_codes(list(atom())) -> hocon_schema:fields()).
+-spec error_codes(list(atom())) -> hocon_schema:fields().
 error_codes(Codes) ->
     error_codes(Codes, <<"Error code to troubleshoot problems.">>).
 
--spec(error_codes(nonempty_list(atom()), binary()) -> hocon_schema:fields()).
+-spec error_codes(nonempty_list(atom()), binary()) -> hocon_schema:fields().
 error_codes(Codes = [_ | _], MsgExample) ->
     [
         {code, hoconsc:mk(hoconsc:enum(Codes))},
-        {message, hoconsc:mk(string(), #{
-            desc => <<"Details description of the error.">>,
-            example => MsgExample
-        })}
+        {message,
+            hoconsc:mk(string(), #{
+                desc => <<"Details description of the error.">>,
+                example => MsgExample
+            })}
     ].
 
 %%------------------------------------------------------------------------------
@@ -143,10 +178,13 @@ translate_req(Request, #{module := Module, path := Path, method := Method}, Chec
         {Bindings, QueryStr} = check_parameters(Request, Params, Module),
         NewBody = check_request_body(Request, Body, Module, CheckFun, hoconsc:is_schema(Body)),
         {ok, Request#{bindings => Bindings, query_string => QueryStr, body => NewBody}}
-    catch throw:{_, ValidErrors} ->
-        Msg = [io_lib:format("~ts : ~p", [Key, Reason]) ||
-            {validation_error, #{path := Key, reason := Reason}} <- ValidErrors],
-        {400, 'BAD_REQUEST', iolist_to_binary(string:join(Msg, ","))}
+    catch
+        throw:{_, ValidErrors} ->
+            Msg = [
+                io_lib:format("~ts : ~p", [Key, Reason])
+             || {validation_error, #{path := Key, reason := Reason}} <- ValidErrors
+            ],
+            {400, 'BAD_REQUEST', iolist_to_binary(string:join(Msg, ","))}
     end.
 
 check_and_translate(Schema, Map, Opts) ->
@@ -169,30 +207,51 @@ parse_spec_ref(Module, Path, Options) ->
     Schema =
         try
             erlang:apply(Module, schema, [Path])
-        catch error: Reason -> %% better error message
-            throw({error, #{mfa => {Module, schema, [Path]}, reason => Reason}})
+            %% better error message
+        catch
+            error:Reason ->
+                throw({error, #{mfa => {Module, schema, [Path]}, reason => Reason}})
+        end,
+    {Specs, Refs} = maps:fold(
+        fun(Method, Meta, {Acc, RefsAcc}) ->
+            (not lists:member(Method, ?METHODS)) andalso
+                throw({error, #{module => Module, path => Path, method => Method}}),
+            {Spec, SubRefs} = meta_to_spec(Meta, Module, Options),
+            {Acc#{Method => Spec}, SubRefs ++ RefsAcc}
         end,
-    {Specs, Refs} = maps:fold(fun(Method, Meta, {Acc, RefsAcc}) ->
-        (not lists:member(Method, ?METHODS))
-            andalso throw({error, #{module => Module, path => Path, method => Method}}),
-        {Spec, SubRefs} = meta_to_spec(Meta, Module, Options),
-        {Acc#{Method => Spec}, SubRefs ++ RefsAcc}
-                              end, {#{}, []},
-        maps:without(['operationId'], Schema)),
+        {#{}, []},
+        maps:without(['operationId'], Schema)
+    ),
     {maps:get('operationId', Schema), Specs, Refs}.
 
 check_parameters(Request, Spec, Module) ->
     #{bindings := Bindings, query_string := QueryStr} = Request,
-    BindingsBin = maps:fold(fun(Key, Value, Acc) ->
-        Acc#{atom_to_binary(Key) => Value}
-                            end, #{}, Bindings),
+    BindingsBin = maps:fold(
+        fun(Key, Value, Acc) ->
+            Acc#{atom_to_binary(Key) => Value}
+        end,
+        #{},
+        Bindings
+    ),
     check_parameter(Spec, BindingsBin, QueryStr, Module, #{}, #{}).
 
 check_parameter([?REF(Fields) | Spec], Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc) ->
-    check_parameter([?R_REF(LocalMod, Fields) | Spec],
-        Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc);
-check_parameter([?R_REF(Module, Fields) | Spec],
-    Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc) ->
+    check_parameter(
+        [?R_REF(LocalMod, Fields) | Spec],
+        Bindings,
+        QueryStr,
+        LocalMod,
+        BindingsAcc,
+        QueryStrAcc
+    );
+check_parameter(
+    [?R_REF(Module, Fields) | Spec],
+    Bindings,
+    QueryStr,
+    LocalMod,
+    BindingsAcc,
+    QueryStrAcc
+) ->
     Params = apply(Module, fields, [Fields]),
     check_parameter(Params ++ Spec, Bindings, QueryStr, LocalMod, BindingsAcc, QueryStrAcc);
 check_parameter([], _Bindings, _QueryStr, _Module, NewBindings, NewQueryStr) ->
@@ -209,7 +268,7 @@ check_parameter([{Name, Type} | Spec], Bindings, QueryStr, Module, BindingsAcc,
             Option = #{},
             NewQueryStr = hocon_tconf:check_plain(Schema, QueryStr, Option),
             NewQueryStrAcc = maps:merge(QueryStrAcc, NewQueryStr),
-            check_parameter(Spec, Bindings, QueryStr, Module,BindingsAcc, NewQueryStrAcc)
+            check_parameter(Spec, Bindings, QueryStr, Module, BindingsAcc, NewQueryStrAcc)
     end.
 
 check_request_body(#{body := Body}, Schema, Module, CheckFun, true) ->
@@ -230,15 +289,18 @@ check_request_body(#{body := Body}, Schema, Module, CheckFun, true) ->
 %%                   {good_nest_2, mk(ref(?MODULE, good_ref), #{})}
 %%                ]}
 %% ]
-check_request_body(#{body := Body}, Spec, _Module, CheckFun, false)when is_list(Spec) ->
-    lists:foldl(fun({Name, Type}, Acc) ->
-        Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]},
-        maps:merge(Acc, CheckFun(Schema, Body, #{}))
-                end, #{}, Spec);
-
+check_request_body(#{body := Body}, Spec, _Module, CheckFun, false) when is_list(Spec) ->
+    lists:foldl(
+        fun({Name, Type}, Acc) ->
+            Schema = ?INIT_SCHEMA#{roots => [{Name, Type}]},
+            maps:merge(Acc, CheckFun(Schema, Body, #{}))
+        end,
+        #{},
+        Spec
+    );
 %% requestBody => #{content => #{ 'application/octet-stream' =>
 %% #{schema => #{ type => string, format => binary}}}
-check_request_body(#{body := Body}, Spec, _Module, _CheckFun, false)when is_map(Spec) ->
+check_request_body(#{body := Body}, Spec, _Module, _CheckFun, false) when is_map(Spec) ->
     Body.
 
 %% tags, description, summary, security, deprecated
@@ -268,69 +330,104 @@ generate_method_desc(Spec) ->
 
 parameters(Params, Module) ->
     {SpecList, AllRefs} =
-        lists:foldl(fun(Param, {Acc, RefsAcc}) ->
-            case Param of
-                ?REF(StructName) -> to_ref(Module, StructName, Acc, RefsAcc);
-                ?R_REF(RModule, StructName) -> to_ref(RModule, StructName, Acc, RefsAcc);
-                {Name, Type} ->
-                    In = hocon_schema:field_schema(Type, in),
-                    In =:= undefined andalso
-                        throw({error, <<"missing in:path/query field in parameters">>}),
-                    Required = hocon_schema:field_schema(Type, required),
-                    Default = hocon_schema:field_schema(Type, default),
-                    HoconType = hocon_schema:field_schema(Type, type),
-                    Meta = init_meta(Default),
-                    {ParamType, Refs} = hocon_schema_to_spec(HoconType, Module),
-                    Spec0 = init_prop([required | ?DEFAULT_FIELDS],
-                        #{schema => maps:merge(ParamType, Meta), name => Name, in => In}, Type),
-                    Spec1 = trans_required(Spec0, Required, In),
-                    Spec2 = trans_desc(Spec1, Type),
-                    {[Spec2 | Acc], Refs ++ RefsAcc}
-            end
-                    end, {[], []}, Params),
+        lists:foldl(
+            fun(Param, {Acc, RefsAcc}) ->
+                case Param of
+                    ?REF(StructName) ->
+                        to_ref(Module, StructName, Acc, RefsAcc);
+                    ?R_REF(RModule, StructName) ->
+                        to_ref(RModule, StructName, Acc, RefsAcc);
+                    {Name, Type} ->
+                        In = hocon_schema:field_schema(Type, in),
+                        In =:= undefined andalso
+                            throw({error, <<"missing in:path/query field in parameters">>}),
+                        Required = hocon_schema:field_schema(Type, required),
+                        Default = hocon_schema:field_schema(Type, default),
+                        HoconType = hocon_schema:field_schema(Type, type),
+                        Meta = init_meta(Default),
+                        {ParamType, Refs} = hocon_schema_to_spec(HoconType, Module),
+                        Spec0 = init_prop(
+                            [required | ?DEFAULT_FIELDS],
+                            #{schema => maps:merge(ParamType, Meta), name => Name, in => In},
+                            Type
+                        ),
+                        Spec1 = trans_required(Spec0, Required, In),
+                        Spec2 = trans_description(Spec1, Type),
+                        {[Spec2 | Acc], Refs ++ RefsAcc}
+                end
+            end,
+            {[], []},
+            Params
+        ),
     {lists:reverse(SpecList), AllRefs}.
 
 init_meta(undefined) -> #{};
 init_meta(Default) -> #{default => Default}.
 
 init_prop(Keys, Init, Type) ->
-    lists:foldl(fun(Key, Acc) ->
-        case hocon_schema:field_schema(Type, Key) of
-            undefined -> Acc;
-            Schema -> Acc#{Key => to_bin(Schema)}
-        end
-                end, Init, Keys).
+    lists:foldl(
+        fun(Key, Acc) ->
+            case hocon_schema:field_schema(Type, Key) of
+                undefined -> Acc;
+                Schema -> Acc#{Key => to_bin(Schema)}
+            end
+        end,
+        Init,
+        Keys
+    ).
 
 trans_required(Spec, true, _) -> Spec#{required => true};
 trans_required(Spec, _, path) -> Spec#{required => true};
 trans_required(Spec, _, _) -> Spec.
 
 trans_desc(Init, Hocon, Func, Name) ->
-    Spec0 = trans_desc(Init, Hocon),
+    Spec0 = trans_description(Init, Hocon),
     case Func =:= fun hocon_schema_to_spec/2 of
-        true -> Spec0;
+        true ->
+            Spec0;
         false ->
-            Spec1 = Spec0#{label => Name},
+            Spec1 = trans_label(Spec0, Hocon, Name),
             case Spec1 of
                 #{description := _} -> Spec1;
                 _ -> Spec1#{description => <<Name/binary, " Description">>}
             end
     end.
 
-trans_desc(Spec, Hocon) ->
+trans_description(Spec, Hocon) ->
+    case trans_desc(<<"desc">>, Hocon, undefined) of
+        undefined -> Spec;
+        Value -> Spec#{description => Value}
+    end.
+
+trans_label(Spec, Hocon, Default) ->
+    Label = trans_desc(<<"label">>, Hocon, Default),
+    Spec#{label => Label}.
+
+trans_desc(Key, Hocon, Default) ->
+    case resolve_desc(Key, desc_struct(Hocon)) of
+        undefined -> Default;
+        Value -> to_bin(Value)
+    end.
+
+desc_struct(Hocon) ->
     case hocon_schema:field_schema(Hocon, desc) of
-        undefined ->
-            case hocon_schema:field_schema(Hocon, description) of
-                undefined ->
-                    Spec;
-                Desc ->
-                    Spec#{description => to_bin(Desc)}
-            end;
-        Desc -> Spec#{description => to_bin(Desc)}
+        undefined -> hocon_schema:field_schema(Hocon, description);
+        Struct -> Struct
+    end.
+
+resolve_desc(_Key, Bin) when is_binary(Bin) -> Bin;
+resolve_desc(Key, Struct) ->
+    {ok, #{cache := Cache, lang := Lang}} = emqx_dashboard:get_i18n(),
+    Desc = hocon_schema:resolve_schema(Struct, Cache),
+    case is_map(Desc) of
+        true -> emqx_map_lib:deep_get([Key, Lang], Desc, undefined);
+        false -> Desc
     end.
 
-request_body(#{content := _} = Content, _Module) -> {Content, []};
-request_body([], _Module) -> {[], []};
+request_body(#{content := _} = Content, _Module) ->
+    {Content, []};
+request_body([], _Module) ->
+    {[], []};
 request_body(Schema, Module) ->
     {{Props, Refs}, Examples} =
         case hoconsc:is_schema(Schema) of
@@ -338,10 +435,10 @@ request_body(Schema, Module) ->
                 HoconSchema = hocon_schema:field_schema(Schema, type),
                 SchemaExamples = hocon_schema:field_schema(Schema, examples),
                 {hocon_schema_to_spec(HoconSchema, Module), SchemaExamples};
-            false -> {parse_object(Schema, Module, #{}), undefined}
+            false ->
+                {parse_object(Schema, Module, #{}), undefined}
         end,
-    {#{<<"content">> => content(Props, Examples)},
-        Refs}.
+    {#{<<"content">> => content(Props, Examples)}, Refs}.
 
 responses(Responses, Module, Options) ->
     {Spec, Refs, _, _} = maps:fold(fun response/3, {#{}, [], Module, Options}, Responses),
@@ -359,33 +456,49 @@ response(Status, ?R_REF(_Mod, _Name) = RRef, {Acc, RefsAcc, Module, Options}) ->
     SchemaToSpec = schema_converter(Options),
     {Spec, Refs} = SchemaToSpec(RRef, Module),
     Content = content(Spec),
-    {Acc#{integer_to_binary(Status) =>
-    #{<<"content">> => Content}}, Refs ++ RefsAcc, Module, Options};
+    {
+        Acc#{
+            integer_to_binary(Status) =>
+                #{<<"content">> => Content}
+        },
+        Refs ++ RefsAcc,
+        Module,
+        Options
+    };
 response(Status, Schema, {Acc, RefsAcc, Module, Options}) ->
     case hoconsc:is_schema(Schema) of
         true ->
             Hocon = hocon_schema:field_schema(Schema, type),
             Examples = hocon_schema:field_schema(Schema, examples),
             {Spec, Refs} = hocon_schema_to_spec(Hocon, Module),
-            Init = trans_desc(#{}, Schema),
+            Init = trans_description(#{}, Schema),
             Content = content(Spec, Examples),
             {
                 Acc#{integer_to_binary(Status) => Init#{<<"content">> => Content}},
-                    Refs ++ RefsAcc, Module, Options
+                Refs ++ RefsAcc,
+                Module,
+                Options
             };
         false ->
             {Props, Refs} = parse_object(Schema, Module, Options),
-            Init = trans_desc(#{}, Schema),
+            Init = trans_description(#{}, Schema),
             Content = Init#{<<"content">> => content(Props)},
             {Acc#{integer_to_binary(Status) => Content}, Refs ++ RefsAcc, Module, Options}
     end.
 
 components(Refs, Options) ->
-    lists:sort(maps:fold(fun(K, V, Acc) -> [#{K => V} | Acc] end, [],
-        components(Options, Refs, #{}, []))).
-
-components(_Options, [], SpecAcc, []) -> SpecAcc;
-components(Options, [], SpecAcc, SubRefAcc) -> components(Options, SubRefAcc, SpecAcc, []);
+    lists:sort(
+        maps:fold(
+            fun(K, V, Acc) -> [#{K => V} | Acc] end,
+            [],
+            components(Options, Refs, #{}, [])
+        )
+    ).
+
+components(_Options, [], SpecAcc, []) ->
+    SpecAcc;
+components(Options, [], SpecAcc, SubRefAcc) ->
+    components(Options, SubRefAcc, SpecAcc, []);
 components(Options, [{Module, Field} | Refs], SpecAcc, SubRefsAcc) ->
     Props = hocon_schema_fields(Module, Field),
     Namespace = namespace(Module),
@@ -404,7 +517,9 @@ hocon_schema_fields(Module, StructName) ->
     case apply(Module, fields, [StructName]) of
         #{fields := Fields, desc := _} ->
             %% evil here, as it's match hocon_schema's internal representation
-            Fields; %% TODO: make use of desc ?
+
+            %% TODO: make use of desc ?
+            Fields;
         Other ->
             Other
     end.
@@ -415,15 +530,13 @@ hocon_schema_fields(Module, StructName) ->
 namespace(Module) ->
     case hocon_schema:namespace(Module) of
         undefined -> Module;
-        NameSpace -> re:replace(to_bin(NameSpace), ":","-",[global])
+        NameSpace -> re:replace(to_bin(NameSpace), ":", "-", [global])
     end.
 
 hocon_schema_to_spec(?R_REF(Module, StructName), _LocalModule) ->
-    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)},
-        [{Module, StructName}]};
+    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(Module, StructName)}, [{Module, StructName}]};
 hocon_schema_to_spec(?REF(StructName), LocalModule) ->
-    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)},
-        [{LocalModule, StructName}]};
+    {#{<<"$ref">> => ?TO_COMPONENTS_SCHEMA(LocalModule, StructName)}, [{LocalModule, StructName}]};
 hocon_schema_to_spec(Type, LocalModule) when ?IS_TYPEREFL(Type) ->
     {typename_to_spec(typerefl:name(Type), LocalModule), []};
 hocon_schema_to_spec(?ARRAY(Item), LocalModule) ->
@@ -435,57 +548,99 @@ hocon_schema_to_spec(?ENUM(Items), _LocalModule) ->
     {#{type => string, enum => Items}, []};
 hocon_schema_to_spec(?MAP(Name, Type), LocalModule) ->
     {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
-    {#{<<"type">> => object,
-        <<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema}},
-        SubRefs};
+    {
+        #{
+            <<"type">> => object,
+            <<"properties">> => #{<<"$", (to_bin(Name))/binary>> => Schema}
+        },
+        SubRefs
+    };
 hocon_schema_to_spec(?UNION(Types), LocalModule) ->
-    {OneOf, Refs} = lists:foldl(fun(Type, {Acc, RefsAcc}) ->
-        {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
-        {[Schema | Acc], SubRefs ++ RefsAcc}
-                                end, {[], []}, Types),
+    {OneOf, Refs} = lists:foldl(
+        fun(Type, {Acc, RefsAcc}) ->
+            {Schema, SubRefs} = hocon_schema_to_spec(Type, LocalModule),
+            {[Schema | Acc], SubRefs ++ RefsAcc}
+        end,
+        {[], []},
+        Types
+    ),
     {#{<<"oneOf">> => OneOf}, Refs};
 hocon_schema_to_spec(Atom, _LocalModule) when is_atom(Atom) ->
     {#{type => string, enum => [Atom]}, []}.
 
 %% todo: Find a way to fetch enum value from user_id_type().
-typename_to_spec("user_id_type()", _Mod) -> #{type => string, enum => [clientid, username]};
-typename_to_spec("term()", _Mod) -> #{type => string, example => "any"};
-typename_to_spec("boolean()", _Mod) -> #{type => boolean, example => true};
-typename_to_spec("binary()", _Mod) -> #{type => string, example => <<"binary-example">>};
-typename_to_spec("float()", _Mod) -> #{type => number, example => 3.14159};
-typename_to_spec("integer()", _Mod) -> #{type => integer, example => 100};
-typename_to_spec("non_neg_integer()", _Mod) -> #{type => integer, minimum => 1, example => 100};
-typename_to_spec("number()", _Mod) -> #{type => number, example => 42};
-typename_to_spec("string()", _Mod) -> #{type => string, example => <<"string-example">>};
-typename_to_spec("atom()", _Mod) -> #{type => string, example => atom};
+typename_to_spec("user_id_type()", _Mod) ->
+    #{type => string, enum => [clientid, username]};
+typename_to_spec("term()", _Mod) ->
+    #{type => string, example => "any"};
+typename_to_spec("boolean()", _Mod) ->
+    #{type => boolean, example => true};
+typename_to_spec("binary()", _Mod) ->
+    #{type => string, example => <<"binary-example">>};
+typename_to_spec("float()", _Mod) ->
+    #{type => number, example => 3.14159};
+typename_to_spec("integer()", _Mod) ->
+    #{type => integer, example => 100};
+typename_to_spec("non_neg_integer()", _Mod) ->
+    #{type => integer, minimum => 1, example => 100};
+typename_to_spec("number()", _Mod) ->
+    #{type => number, example => 42};
+typename_to_spec("string()", _Mod) ->
+    #{type => string, example => <<"string-example">>};
+typename_to_spec("atom()", _Mod) ->
+    #{type => string, example => atom};
 typename_to_spec("epoch_second()", _Mod) ->
-    #{<<"oneOf">> => [
-        #{type => integer, example => 1640995200, description => <<"epoch-second">>},
-        #{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}]
-        };
+    #{
+        <<"oneOf">> => [
+            #{type => integer, example => 1640995200, description => <<"epoch-second">>},
+            #{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}
+        ]
+    };
 typename_to_spec("epoch_millisecond()", _Mod) ->
-    #{<<"oneOf">> => [
-        #{type => integer, example => 1640995200000, description => <<"epoch-millisecond">>},
-        #{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}]
+    #{
+        <<"oneOf">> => [
+            #{type => integer, example => 1640995200000, description => <<"epoch-millisecond">>},
+            #{type => string, example => <<"2022-01-01T00:00:00.000Z">>, format => <<"date-time">>}
+        ]
     };
-typename_to_spec("duration()", _Mod) -> #{type => string, example => <<"12m">>};
-typename_to_spec("duration_s()", _Mod) -> #{type => string, example => <<"1h">>};
-typename_to_spec("duration_ms()", _Mod) -> #{type => string, example => <<"32s">>};
-typename_to_spec("percent()", _Mod) -> #{type => number, example => <<"12%">>};
-typename_to_spec("file()", _Mod) -> #{type => string, example => <<"/path/to/file">>};
-typename_to_spec("ip_port()", _Mod) -> #{type => string, example => <<"127.0.0.1:80">>};
+typename_to_spec("duration()", _Mod) ->
+    #{type => string, example => <<"12m">>};
+typename_to_spec("duration_s()", _Mod) ->
+    #{type => string, example => <<"1h">>};
+typename_to_spec("duration_ms()", _Mod) ->
+    #{type => string, example => <<"32s">>};
+typename_to_spec("percent()", _Mod) ->
+    #{type => number, example => <<"12%">>};
+typename_to_spec("file()", _Mod) ->
+    #{type => string, example => <<"/path/to/file">>};
+typename_to_spec("ip_port()", _Mod) ->
+    #{type => string, example => <<"127.0.0.1:80">>};
 typename_to_spec("ip_ports()", _Mod) ->
     #{type => string, example => <<"127.0.0.1:80, 127.0.0.2:80">>};
-typename_to_spec("url()", _Mod) -> #{type => string, example => <<"http://127.0.0.1">>};
-typename_to_spec("connect_timeout()", Mod) -> typename_to_spec("timeout()", Mod);
-typename_to_spec("timeout()", _Mod) -> #{<<"oneOf">> => [#{type => string, example => infinity},
-    #{type => integer, example => 100}], example => infinity};
-typename_to_spec("bytesize()", _Mod) -> #{type => string, example => <<"32MB">>};
-typename_to_spec("wordsize()", _Mod) -> #{type => string, example => <<"1024KB">>};
-typename_to_spec("map()", _Mod) -> #{type => object, example => #{}};
-typename_to_spec("#{" ++ _, Mod) -> typename_to_spec("map()", Mod);
-typename_to_spec("qos()", _Mod) -> #{type => string, enum => [0, 1, 2], example => 0};
-typename_to_spec("{binary(), binary()}", _Mod) -> #{type => object, example => #{}};
+typename_to_spec("url()", _Mod) ->
+    #{type => string, example => <<"http://127.0.0.1">>};
+typename_to_spec("connect_timeout()", Mod) ->
+    typename_to_spec("timeout()", Mod);
+typename_to_spec("timeout()", _Mod) ->
+    #{
+        <<"oneOf">> => [
+            #{type => string, example => infinity},
+            #{type => integer, example => 100}
+        ],
+        example => infinity
+    };
+typename_to_spec("bytesize()", _Mod) ->
+    #{type => string, example => <<"32MB">>};
+typename_to_spec("wordsize()", _Mod) ->
+    #{type => string, example => <<"1024KB">>};
+typename_to_spec("map()", _Mod) ->
+    #{type => object, example => #{}};
+typename_to_spec("#{" ++ _, Mod) ->
+    typename_to_spec("map()", Mod);
+typename_to_spec("qos()", _Mod) ->
+    #{type => string, enum => [0, 1, 2], example => 0};
+typename_to_spec("{binary(), binary()}", _Mod) ->
+    #{type => object, example => #{}};
 typename_to_spec("comma_separated_list()", _Mod) ->
     #{type => string, example => <<"item1,item2">>};
 typename_to_spec("comma_separated_atoms()", _Mod) ->
@@ -493,8 +648,9 @@ typename_to_spec("comma_separated_atoms()", _Mod) ->
 typename_to_spec("pool_type()", _Mod) ->
     #{type => string, enum => [random, hash], example => hash};
 typename_to_spec("log_level()", _Mod) ->
-    #{ type => string,
-       enum => [debug, info, notice, warning, error, critical, alert, emergency, all]
+    #{
+        type => string,
+        enum => [debug, info, notice, warning, error, critical, alert, emergency, all]
     };
 typename_to_spec("rate()", _Mod) ->
     #{type => string, example => <<"10M/s">>};
@@ -515,16 +671,18 @@ typename_to_spec(Name, Mod) ->
     Spec2 = typerefl_array(Spec1, Name, Mod),
     Spec3 = integer(Spec2, Name),
     Spec3 =:= nomatch andalso
-                 throw({error, #{msg => <<"Unsupported Type">>, type => Name, module => Mod}}),
-             Spec3.
+        throw({error, #{msg => <<"Unsupported Type">>, type => Name, module => Mod}}),
+    Spec3.
 
 range(Name) ->
     case string:split(Name, "..") of
-        [MinStr, MaxStr] -> %% 1..10 1..inf -inf..10
+        %% 1..10 1..inf -inf..10
+        [MinStr, MaxStr] ->
             Schema = #{type => integer},
             Schema1 = add_integer_prop(Schema, minimum, MinStr),
             add_integer_prop(Schema1, maximum, MaxStr);
-        _ -> nomatch
+        _ ->
+            nomatch
     end.
 
 %% Module:Type
@@ -533,21 +691,25 @@ remote_module_type(nomatch, Name, Mod) ->
         [_Module, Type] -> typename_to_spec(Type, Mod);
         _ -> nomatch
     end;
-remote_module_type(Spec, _Name, _Mod) -> Spec.
+remote_module_type(Spec, _Name, _Mod) ->
+    Spec.
 
 %% [string()] or [integer()] or [xxx].
 typerefl_array(nomatch, Name, Mod) ->
     case string:trim(Name, leading, "[") of
-        Name -> nomatch;
+        Name ->
+            nomatch;
         Name1 ->
             case string:trim(Name1, trailing, "]") of
-                Name1 -> notmatch;
+                Name1 ->
+                    notmatch;
                 Name2 ->
                     Schema = typename_to_spec(Name2, Mod),
                     #{type => array, items => Schema}
             end
     end;
-typerefl_array(Spec, _Name, _Mod) -> Spec.
+typerefl_array(Spec, _Name, _Mod) ->
+    Spec.
 
 %% integer(1)
 integer(nomatch, Name) ->
@@ -555,12 +717,13 @@ integer(nomatch, Name) ->
         {Int, []} -> #{type => integer, enum => [Int], example => Int, default => Int};
         _ -> nomatch
     end;
-integer(Spec, _Name) -> Spec.
+integer(Spec, _Name) ->
+    Spec.
 
 add_integer_prop(Schema, Key, Value) ->
     case string:to_integer(Value) of
         {error, no_integer} -> Schema;
-        {Int, []}when Key =:= minimum -> Schema#{Key => Int, example => Int};
+        {Int, []} when Key =:= minimum -> Schema#{Key => Int, example => Int};
         {Int, []} -> Schema#{Key => Int}
     end.
 
@@ -571,39 +734,53 @@ to_bin(List) when is_list(List) ->
     end;
 to_bin(Boolean) when is_boolean(Boolean) -> Boolean;
 to_bin(Atom) when is_atom(Atom) -> atom_to_binary(Atom, utf8);
-to_bin(X) -> X.
+to_bin(X) ->
+    X.
 
 parse_object(PropList = [_ | _], Module, Options) when is_list(PropList) ->
     {Props, Required, Refs} =
-        lists:foldl(fun({Name, Hocon}, {Acc, RequiredAcc, RefsAcc}) ->
-            NameBin = to_bin(Name),
-            case hoconsc:is_schema(Hocon) of
-                true ->
-                    HoconType = hocon_schema:field_schema(Hocon, type),
-                    Init0 = init_prop([default | ?DEFAULT_FIELDS], #{}, Hocon),
-                    SchemaToSpec = schema_converter(Options),
-                    Init = trans_desc(Init0, Hocon, SchemaToSpec, NameBin),
-                    {Prop, Refs1} = SchemaToSpec(HoconType, Module),
-                    NewRequiredAcc =
-                        case is_required(Hocon) of
-                            true -> [NameBin | RequiredAcc];
-                            false -> RequiredAcc
-                        end,
-                    {[{NameBin, maps:merge(Prop, Init)} | Acc], NewRequiredAcc, Refs1 ++ RefsAcc};
-                false ->
-                    {SubObject, SubRefs} = parse_object(Hocon, Module, Options),
-                    {[{NameBin, SubObject} | Acc], RequiredAcc, SubRefs ++ RefsAcc}
-            end
-                    end, {[], [], []}, PropList),
+        lists:foldl(
+            fun({Name, Hocon}, {Acc, RequiredAcc, RefsAcc}) ->
+                NameBin = to_bin(Name),
+                case hoconsc:is_schema(Hocon) of
+                    true ->
+                        HoconType = hocon_schema:field_schema(Hocon, type),
+                        Init0 = init_prop([default | ?DEFAULT_FIELDS], #{}, Hocon),
+                        SchemaToSpec = schema_converter(Options),
+                        Init = trans_desc(Init0, Hocon, SchemaToSpec, NameBin),
+                        {Prop, Refs1} = SchemaToSpec(HoconType, Module),
+                        NewRequiredAcc =
+                            case is_required(Hocon) of
+                                true -> [NameBin | RequiredAcc];
+                                false -> RequiredAcc
+                            end,
+                        {
+                            [{NameBin, maps:merge(Prop, Init)} | Acc],
+                            NewRequiredAcc,
+                            Refs1 ++ RefsAcc
+                        };
+                    false ->
+                        {SubObject, SubRefs} = parse_object(Hocon, Module, Options),
+                        {[{NameBin, SubObject} | Acc], RequiredAcc, SubRefs ++ RefsAcc}
+                end
+            end,
+            {[], [], []},
+            PropList
+        ),
     Object = #{<<"type">> => object, <<"properties">> => lists:reverse(Props)},
     case Required of
         [] -> {Object, Refs};
         _ -> {maps:put(required, Required, Object), Refs}
     end;
 parse_object(Other, Module, Options) ->
-    erlang:throw({error,
-        #{msg => <<"Object only supports not empty proplists">>,
-            args => Other, module => Module, options => Options}}).
+    erlang:throw(
+        {error, #{
+            msg => <<"Object only supports not empty proplists">>,
+            args => Other,
+            module => Module,
+            options => Options
+        }}
+    ).
 
 is_required(Hocon) ->
     hocon_schema:field_schema(Hocon, required) =:= true.

+ 1 - 1
apps/emqx_prometheus/rebar.config

@@ -4,7 +4,7 @@
  [ {emqx, {path, "../emqx"}},
    %% FIXME: tag this as v3.1.3
    {prometheus, {git, "https://github.com/deadtrickster/prometheus.erl", {tag, "v4.8.1"}}},
-   {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.6"}}}
+   {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.7"}}}
  ]}.
 
 {edoc_opts, [{preprocess, true}]}.

+ 7 - 1
mix.exs

@@ -68,7 +68,7 @@ defmodule EMQXUmbrella.MixProject do
       # in conflict by emqtt and hocon
       {:getopt, "1.0.2", override: true},
       {:snabbkaffe, github: "kafka4beam/snabbkaffe", tag: "0.18.0", override: true},
-      {:hocon, github: "emqx/hocon", tag: "0.26.6", override: true},
+      {:hocon, github: "emqx/hocon", tag: "0.26.7", override: true},
       {:emqx_http_lib, github: "emqx/emqx_http_lib", tag: "0.4.1", override: true},
       {:esasl, github: "emqx/esasl", tag: "0.2.0"},
       {:jose, github: "potatosalad/erlang-jose", tag: "1.11.2"},
@@ -341,6 +341,12 @@ defmodule EMQXUmbrella.MixProject do
       "apps/emqx/etc/certs",
       Path.join(etc, "certs")
     )
+    # required by emqx_dashboard
+     Mix.Generator.copy_file(
+      "apps/emqx_dashboard/etc/i18n.conf",
+      Path.join(etc, "i18n.conf"),
+      force: overwrite?
+     )
 
     # this is required by the produced escript / nodetool
     Mix.Generator.copy_file(

+ 1 - 1
rebar.config

@@ -66,7 +66,7 @@
     , {system_monitor, {git, "https://github.com/ieQu1/system_monitor", {tag, "3.0.2"}}}
     , {getopt, "1.0.2"}
     , {snabbkaffe, {git, "https://github.com/kafka4beam/snabbkaffe.git", {tag, "0.18.0"}}}
-    , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.6"}}}
+    , {hocon, {git, "https://github.com/emqx/hocon.git", {tag, "0.26.7"}}}
     , {emqx_http_lib, {git, "https://github.com/emqx/emqx_http_lib.git", {tag, "0.4.1"}}}
     , {esasl, {git, "https://github.com/emqx/esasl", {tag, "0.2.0"}}}
     , {jose, {git, "https://github.com/potatosalad/erlang-jose", {tag, "1.11.2"}}}

+ 2 - 0
rebar.config.erl

@@ -456,10 +456,12 @@ emqx_etc_overlay_common() ->
 
 emqx_etc_overlay_per_edition(ce) ->
     [ {"{{base_dir}}/lib/emqx_conf/etc/emqx.conf.all", "etc/emqx.conf"}
+    , {"{{base_dir}}/lib/emqx_dashboard/etc/i18n.conf.all", "etc/i18n.conf"}
     ];
 emqx_etc_overlay_per_edition(ee) ->
     [ {"{{base_dir}}/lib/emqx_conf/etc/emqx_enterprise.conf.all", "etc/emqx_enterprise.conf"}
     , {"{{base_dir}}/lib/emqx_conf/etc/emqx.conf.all", "etc/emqx.conf"}
+    , {"{{base_dir}}/lib/emqx_dashboard/etc/i18n.conf.all", "etc/i18n.conf"}
     ].
 
 get_vsn(Profile) ->

+ 2 - 1
scripts/merge-config.escript

@@ -62,7 +62,8 @@ get_cfgs(Dir, Cfgs) ->
                     %% the conf name must start with emqx
                     %% because there are some other conf, and these conf don't start with emqx
                     Confs = filelib:wildcard("emqx*.conf", EtcDir),
-                    NewCfgs = [filename:join([EtcDir, Name]) || Name <- Confs],
+                    Confs1 = lists:filter(fun(N) -> string:find(N, "i18n") =:= nomatch end, Confs),
+                    NewCfgs = [filename:join([EtcDir, Name]) || Name <- Confs1],
                     try_enter_child(Dir, Files, NewCfgs ++ Cfgs)
             end
     end.

+ 62 - 0
scripts/merge-i18n.escript

@@ -0,0 +1,62 @@
+#!/usr/bin/env escript
+
+-mode(compile).
+
+main(_) ->
+    {ok, BaseConf} = file:read_file("apps/emqx_dashboard/etc/emqx_dashboard_i18n.conf"),
+
+    Cfgs = get_all_cfgs("apps/"),
+    Conf = [merge(BaseConf, Cfgs),
+            io_lib:nl()
+            ],
+    ok = file:write_file("apps/emqx_dashboard/etc/i18n.conf.all", Conf).
+
+merge(BaseConf, Cfgs) ->
+    lists:foldl(
+      fun(CfgFile, Acc) ->
+              case filelib:is_regular(CfgFile) of
+                  true ->
+                      {ok, Bin1} = file:read_file(CfgFile),
+                      [Acc, io_lib:nl(), Bin1];
+                  false -> Acc
+              end
+      end, BaseConf, Cfgs).
+
+get_all_cfgs(Root) ->
+    Apps = filelib:wildcard("*", Root) -- ["emqx_machine", "emqx_dashboard"],
+    Dirs = [filename:join([Root, App]) || App <- Apps],
+    lists:foldl(fun get_cfgs/2, [], Dirs).
+
+get_all_cfgs(Dir, Cfgs) ->
+    Fun = fun(E, Acc) ->
+                  Path = filename:join([Dir, E]),
+                  get_cfgs(Path, Acc)
+          end,
+    lists:foldl(Fun, Cfgs, filelib:wildcard("*", Dir)).
+
+get_cfgs(Dir, Cfgs) ->
+    case filelib:is_dir(Dir) of
+        false ->
+            Cfgs;
+        _ ->
+            Files = filelib:wildcard("*", Dir),
+            case lists:member("etc", Files) of
+                false ->
+                    try_enter_child(Dir, Files, Cfgs);
+                true ->
+                    EtcDir = filename:join([Dir, "etc"]),
+                    %% the conf name must start with emqx
+                    %% because there are some other conf, and these conf don't start with emqx
+                    Confs = filelib:wildcard("emqx*_i18n.conf", EtcDir),
+                    NewCfgs = [filename:join([EtcDir, Name]) || Name <- Confs],
+                    try_enter_child(Dir, Files, NewCfgs ++ Cfgs)
+            end
+    end.
+
+try_enter_child(Dir, Files, Cfgs) ->
+    case lists:member("src", Files) of
+        false ->
+            Cfgs;
+        true ->
+            get_all_cfgs(filename:join([Dir, "src"]), Cfgs)
+    end.