浏览代码

fix: Dashboards can now use categories, service looks for $PORT

jamesread 3 周之前
父节点
当前提交
b95d6c683e

+ 8 - 3
config.yaml

@@ -2,7 +2,8 @@
 # one port (this is called the "Single HTTP Frontend") and means you just need
 # one open port in the container/firewalls/etc.
 #
-# Listen on all addresses available, port 1337
+# Listen on all addresses available, port 1337.
+# If the PORT environment variable is set, it overrides this port (host is kept).
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 
 # Choose from INFO (default), WARN and DEBUG
@@ -352,8 +353,10 @@ actionGroups:
 #
 # Docs: https://docs.olivetin.app/dashboards/intro.html
 dashboards:
-  # Top level items are dashboards.
+  # Top level items are dashboards. Optional `category` groups them under
+  # collapsible headings in the sidebar (same label = same group).
   - title: My Servers
+    category: Infrastructure
     contents:
       - title: All Servers
         type: fieldset
@@ -399,8 +402,10 @@ dashboards:
             contents:
               - title: '{{ server.name }} Print server name'
 
-  # This is the second dashboard.
+  # Second dashboard — same category as My Servers, so both appear under
+  # "Infrastructure" in the sidebar.
   - title: My Containers
+    category: Infrastructure
     contents:
       - title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})'
         entity: container

+ 2 - 0
docs/modules/ROOT/pages/advanced_configuration/webui.adoc

@@ -67,6 +67,8 @@ showNavigateOnStartIcons: false
 
 image::advanced_configuration/webui/sidebar/sidebar.png[]
 
+Root dashboards can optionally set a `category` field so the sidebar groups them under collapsible headings. See xref:dashboards/intro.adoc#categorize-dashboards-in-the-sidebar[Categorize dashboards in the sidebar].
+
 === Topbar navigation style
 
 `sectionNavigationStyle: topbar` looks like this;

+ 33 - 0
docs/modules/ROOT/pages/dashboards/intro.adoc

@@ -82,6 +82,39 @@ dashboards:
           - title: '{{ server.name }} Power Off'
 ----
 
+[#categorize-dashboards-in-the-sidebar]
+== Categorize dashboards in the sidebar
+
+When you have several root dashboards, you can group them under collapsible category headings in the sidebar with an optional `category` field on each root dashboard:
+
+[source,yaml]
+.`config.yaml`
+----
+dashboards:
+  - title: My Servers
+    category: Infrastructure
+    contents:
+      - title: Ping All Servers
+
+  - title: My Containers
+    category: Infrastructure
+    contents:
+      - title: Restart nginx
+
+  - title: Status Board
+    category: Monitoring
+    contents:
+      - title: Show uptime
+
+  - title: Misc Tools
+    contents:
+      - title: Hello World
+----
+
+Dashboards that share the same `category` value are grouped together. Category order follows the first time each category appears in the config among dashboards the user can see. Dashboards without a `category` appear above the category sections. Nested items under `contents` do not use `category`.
+
+Categories are shown when `sectionNavigationStyle` is `sidebar` (the default). See xref:advanced_configuration/webui.adoc#section-navgiation-style[Section Navigation Style].
+
 == What's Next?
 
 Now that you understand dashboards, explore these related features:

+ 4 - 4
frontend/package-lock.json

@@ -18,7 +18,7 @@
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.21.1",
+				"picocrank": "^1.21.2",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.1.5",
@@ -5272,9 +5272,9 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.21.1",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.1.tgz",
-			"integrity": "sha512-VnfNFok7BkpLKymAIbD2zUA0bf/AiNBS63Xk2mfi8uXOkdAmf06pQaI0ssGBv2/S9p1jbCATWj9OFz1uqa39NQ==",
+			"version": "1.21.2",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.2.tgz",
+			"integrity": "sha512-bUw6789jyYCTa9waINvYRHmnfgtoMquUidkJx2yhrDCMGbu2O65Qilkl++57yl5JnFeoZOhiPuQTKMPxMQku4A==",
 			"license": "ISC",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.2.2",

+ 1 - 1
frontend/package.json

@@ -32,7 +32,7 @@
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.21.1",
+		"picocrank": "^1.21.2",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.1.5",

+ 26 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1890,6 +1890,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: int32 config_issue_count = 26;
    */
   configIssueCount: number;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27;
+   */
+  rootDashboardEntries: RootDashboard[];
 };
 
 /**
@@ -1898,6 +1903,27 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
  */
 export declare const InitResponseSchema: GenMessage<InitResponse>;
 
+/**
+ * @generated from message olivetin.api.v1.RootDashboard
+ */
+export declare type RootDashboard = Message<"olivetin.api.v1.RootDashboard"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string category = 2;
+   */
+  category: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.RootDashboard.
+ * Use `create(RootDashboardSchema)` to create a new message.
+ */
+export declare const RootDashboardSchema: GenMessage<RootDashboard>;
+
 /**
  * @generated from message olivetin.api.v1.AdditionalLink
  */

文件差异内容过多而无法显示
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 85 - 15
frontend/resources/vue/App.vue

@@ -338,40 +338,110 @@ function updateHeaderFromInit () {
   connectEventStreamIfNeeded()
 }
 
+function getRootDashboardEntries () {
+  const entries = window.initResponse?.rootDashboardEntries
+
+  if (entries && entries.length > 0) {
+    return entries.map((entry) => ({
+      title: entry.title,
+      category: entry.category || ''
+    }))
+  }
+
+  return (window.initResponse?.rootDashboards || []).map((title) => ({
+    title,
+    category: ''
+  }))
+}
+
+function addDashboardNavLink (title) {
+  navigation.value.addNavigationLink({
+    id: title,
+    name: title,
+    title,
+    path: title === 'Actions' ? '/' : `/dashboards/${title}`,
+    icon: DashboardSquare01Icon
+  })
+}
+
+function addCategorizedDashboardLinks (entries) {
+  const categoryOrder = []
+  const byCategory = new Map()
+
+  for (const entry of entries) {
+    const category = entry.category.trim()
+
+    if (!byCategory.has(category)) {
+      byCategory.set(category, [])
+      categoryOrder.push(category)
+    }
+
+    byCategory.get(category).push(entry.title)
+  }
+
+  for (const category of categoryOrder) {
+    navigation.value.addSection(category)
+
+    for (const title of byCategory.get(category)) {
+      addDashboardNavLink(title)
+    }
+  }
+}
+
 function renderNavigation () {
   if (!navigation.value) {
     return
   }
 
-  const rootDashboards = window.initResponse?.rootDashboards || []
-
   if (typeof navigation.value.clearNavigationLinks === 'function') {
     navigation.value.clearNavigationLinks()
   }
 
-  for (const rootDashboard of rootDashboards) {
-    navigation.value.addNavigationLink({
-      id: rootDashboard,
-      name: rootDashboard,
-      title: rootDashboard,
-      path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
-      icon: DashboardSquare01Icon
-    })
+  const entries = getRootDashboardEntries()
+  const uncategorized = entries.filter((entry) => !entry.category.trim())
+  const categorized = entries.filter((entry) => entry.category.trim())
+
+  for (const entry of uncategorized) {
+    addDashboardNavLink(entry.title)
   }
 
-  navigation.value.addSeparator()
-  navigation.value.addRouterLink('Entities', t('nav.entities'))
+  addCategorizedDashboardLinks(categorized)
+  addSystemNavLinks()
+}
+
+function addSystemNavLinks () {
+  const systemLinks = []
+
+  systemLinks.push({
+    routeName: 'Entities',
+    title: t('nav.entities')
+  })
 
   if (showLogs.value) {
-    navigation.value.addRouterLink('Logs', t('nav.logs'))
+    systemLinks.push({
+      routeName: 'Logs',
+      title: t('nav.logs')
+    })
   }
 
   if (showDiagnostics.value) {
     const issueCount = window.initResponse?.configIssueCount || 0
-    navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'), {
-      count: issueCount
+    systemLinks.push({
+      routeName: 'Diagnostics',
+      title: t('nav.diagnostics'),
+      options: { count: issueCount }
     })
   }
+
+  if (systemLinks.length === 0) {
+    return
+  }
+
+  navigation.value.addSection(t('nav.system'))
+
+  for (const link of systemLinks) {
+    navigation.value.addRouterLink(link.routeName, link.title, link.options || {})
+  }
 }
 
 function openLanguageDialog () {

+ 6 - 0
lang/combined_output.json

@@ -82,6 +82,7 @@
             "nav.diagnostics": "Diagnostik",
             "nav.entities": "Entitäten",
             "nav.logs": "Protokolle",
+            "nav.system": "System",
             "raise-issue": "Ein Problem melden auf GitHub",
             "reconnecting": "Verbinde erneut…",
             "return-to-index": "Zurück zur Startseite",
@@ -172,6 +173,7 @@
             "nav.diagnostics": "Diagnostics",
             "nav.entities": "Entities",
             "nav.logs": "Logs",
+            "nav.system": "System",
             "raise-issue": "Raise an issue on GitHub",
             "reconnecting": "Reconnecting…",
             "return-to-index": "Return to index",
@@ -262,6 +264,7 @@
             "nav.diagnostics": "Diagnósticos",
             "nav.entities": "Entidades",
             "nav.logs": "Registros",
+            "nav.system": "Sistema",
             "raise-issue": "Reportar un problema en GitHub",
             "reconnecting": "Reconectando…",
             "return-to-index": "Volver a la página principal",
@@ -352,6 +355,7 @@
             "nav.diagnostics": "Diagnostica",
             "nav.entities": "Entità",
             "nav.logs": "Registri",
+            "nav.system": "Sistema",
             "raise-issue": "Segnala un problema su GitHub",
             "reconnecting": "Riconnessione…",
             "return-to-index": "Torna alla pagina principale",
@@ -442,6 +446,7 @@
             "nav.diagnostics": "诊断",
             "nav.entities": "实体",
             "nav.logs": "日志",
+            "nav.system": "系统",
             "raise-issue": "在 GitHub 上报告问题",
             "reconnecting": "正在重新连接…",
             "return-to-index": "返回首页",
@@ -532,6 +537,7 @@
             "nav.diagnostics": "診斷",
             "nav.entities": "實體",
             "nav.logs": "日誌",
+            "nav.system": "系統",
             "raise-issue": "在 GitHub 上建立 Issue",
             "reconnecting": "重新連線中…",
             "return-to-index": "返回首頁",

+ 1 - 0
lang/de-DE.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Protokolle
   nav.entities: Entitäten
   nav.diagnostics: Diagnostik
+  nav.system: System
   connected: Verbunden
   disconnected: Getrennt
   reconnecting: Verbinde erneut…

+ 1 - 0
lang/en.yaml

@@ -7,6 +7,7 @@ translations:
   nav.logs: Logs
   nav.entities: Entities
   nav.diagnostics: Diagnostics
+  nav.system: System
   connected: Connected
   disconnected: Disconnected
   reconnecting: Reconnecting…

+ 1 - 0
lang/es-ES.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Registros
   nav.entities: Entidades
   nav.diagnostics: Diagnósticos
+  nav.system: Sistema
   connected: Conectado
   disconnected: Desconectado
   reconnecting: Reconectando…

+ 1 - 0
lang/it-IT.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Registri
   nav.entities: Entità
   nav.diagnostics: Diagnostica
+  nav.system: Sistema
   docs: Documentazione
   connected: Connesso
   disconnected: Disconnesso

+ 1 - 0
lang/zh-Hans-CN.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: 日志
   nav.entities: 实体
   nav.diagnostics: 诊断
+  nav.system: 系统
   connected: 已连接
   disconnected: 已断开连接
   reconnecting: 正在重新连接…

+ 1 - 0
lang/zh-Hant-TW.yaml

@@ -7,6 +7,7 @@ translations:
   nav.logs: 日誌
   nav.entities: 實體
   nav.diagnostics: 診斷
+  nav.system: 系統
   connected: 已連線
   disconnected: 已斷線
   reconnecting: 重新連線中…

+ 6 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -425,6 +425,12 @@ message InitResponse {
 	repeated string available_themes = 24; // List of available theme names
 	bool show_navigate_on_start_icons = 25;
 	int32 config_issue_count = 26;
+	repeated RootDashboard root_dashboard_entries = 27;
+}
+
+message RootDashboard {
+	string title = 1;
+	string category = 2;
 }
 
 message AdditionalLink {

+ 190 - 123
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -4095,6 +4095,7 @@ type InitResponse struct {
 	AvailableThemes           []string               `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
 	ShowNavigateOnStartIcons  bool                   `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"`
 	ConfigIssueCount          int32                  `protobuf:"varint,26,opt,name=config_issue_count,json=configIssueCount,proto3" json:"config_issue_count,omitempty"`
+	RootDashboardEntries      []*RootDashboard       `protobuf:"bytes,27,rep,name=root_dashboard_entries,json=rootDashboardEntries,proto3" json:"root_dashboard_entries,omitempty"`
 	unknownFields             protoimpl.UnknownFields
 	sizeCache                 protoimpl.SizeCache
 }
@@ -4311,6 +4312,65 @@ func (x *InitResponse) GetConfigIssueCount() int32 {
 	return 0
 }
 
+func (x *InitResponse) GetRootDashboardEntries() []*RootDashboard {
+	if x != nil {
+		return x.RootDashboardEntries
+	}
+	return nil
+}
+
+type RootDashboard struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
+	Category      string                 `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *RootDashboard) Reset() {
+	*x = RootDashboard{}
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *RootDashboard) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RootDashboard) ProtoMessage() {}
+
+func (x *RootDashboard) ProtoReflect() protoreflect.Message {
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RootDashboard.ProtoReflect.Descriptor instead.
+func (*RootDashboard) Descriptor() ([]byte, []int) {
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
+}
+
+func (x *RootDashboard) GetTitle() string {
+	if x != nil {
+		return x.Title
+	}
+	return ""
+}
+
+func (x *RootDashboard) GetCategory() string {
+	if x != nil {
+		return x.Category
+	}
+	return ""
+}
+
 type AdditionalLink struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -4321,7 +4381,7 @@ type AdditionalLink struct {
 
 func (x *AdditionalLink) Reset() {
 	*x = AdditionalLink{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4333,7 +4393,7 @@ func (x *AdditionalLink) String() string {
 func (*AdditionalLink) ProtoMessage() {}
 
 func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4346,7 +4406,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 func (*AdditionalLink) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
 }
 
 func (x *AdditionalLink) GetTitle() string {
@@ -4374,7 +4434,7 @@ type OAuth2Provider struct {
 
 func (x *OAuth2Provider) Reset() {
 	*x = OAuth2Provider{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4386,7 +4446,7 @@ func (x *OAuth2Provider) String() string {
 func (*OAuth2Provider) ProtoMessage() {}
 
 func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4399,7 +4459,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 func (*OAuth2Provider) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
 }
 
 func (x *OAuth2Provider) GetTitle() string {
@@ -4432,7 +4492,7 @@ type GetActionBindingRequest struct {
 
 func (x *GetActionBindingRequest) Reset() {
 	*x = GetActionBindingRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4444,7 +4504,7 @@ func (x *GetActionBindingRequest) String() string {
 func (*GetActionBindingRequest) ProtoMessage() {}
 
 func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4457,7 +4517,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
 }
 
 func (x *GetActionBindingRequest) GetBindingId() string {
@@ -4477,7 +4537,7 @@ type GetActionBindingResponse struct {
 
 func (x *GetActionBindingResponse) Reset() {
 	*x = GetActionBindingResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4489,7 +4549,7 @@ func (x *GetActionBindingResponse) String() string {
 func (*GetActionBindingResponse) ProtoMessage() {}
 
 func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4502,7 +4562,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
 }
 
 func (x *GetActionBindingResponse) GetAction() *Action {
@@ -4531,7 +4591,7 @@ type GetEntitiesRequest struct {
 
 func (x *GetEntitiesRequest) Reset() {
 	*x = GetEntitiesRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4543,7 +4603,7 @@ func (x *GetEntitiesRequest) String() string {
 func (*GetEntitiesRequest) ProtoMessage() {}
 
 func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4556,7 +4616,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
 }
 
 func (x *GetEntitiesRequest) GetEntityType() string {
@@ -4596,7 +4656,7 @@ type GetEntitiesResponse struct {
 
 func (x *GetEntitiesResponse) Reset() {
 	*x = GetEntitiesResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4608,7 +4668,7 @@ func (x *GetEntitiesResponse) String() string {
 func (*GetEntitiesResponse) ProtoMessage() {}
 
 func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4621,7 +4681,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
 }
 
 func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
@@ -4645,7 +4705,7 @@ type EntityDefinition struct {
 
 func (x *EntityDefinition) Reset() {
 	*x = EntityDefinition{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4657,7 +4717,7 @@ func (x *EntityDefinition) String() string {
 func (*EntityDefinition) ProtoMessage() {}
 
 func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4670,7 +4730,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 func (*EntityDefinition) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
 }
 
 func (x *EntityDefinition) GetTitle() string {
@@ -4725,7 +4785,7 @@ type EntityProperty struct {
 
 func (x *EntityProperty) Reset() {
 	*x = EntityProperty{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4737,7 +4797,7 @@ func (x *EntityProperty) String() string {
 func (*EntityProperty) ProtoMessage() {}
 
 func (x *EntityProperty) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4750,7 +4810,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
 func (*EntityProperty) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
 }
 
 func (x *EntityProperty) GetName() string {
@@ -4777,7 +4837,7 @@ type GetEntityRequest struct {
 
 func (x *GetEntityRequest) Reset() {
 	*x = GetEntityRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4789,7 +4849,7 @@ func (x *GetEntityRequest) String() string {
 func (*GetEntityRequest) ProtoMessage() {}
 
 func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4802,7 +4862,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 func (*GetEntityRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
 }
 
 func (x *GetEntityRequest) GetUniqueKey() string {
@@ -4828,7 +4888,7 @@ type RestartActionRequest struct {
 
 func (x *RestartActionRequest) Reset() {
 	*x = RestartActionRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4840,7 +4900,7 @@ func (x *RestartActionRequest) String() string {
 func (*RestartActionRequest) ProtoMessage() {}
 
 func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4853,7 +4913,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 func (*RestartActionRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{79}
 }
 
 func (x *RestartActionRequest) GetExecutionTrackingId() string {
@@ -5175,7 +5235,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
 	"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\n" +
 	"\rconfig_issues\x18\x03 \x03(\v2\x1c.olivetin.api.v1.ConfigIssueR\fconfigIssues\"\r\n" +
-	"\vInitRequest\"\xbb\t\n" +
+	"\vInitRequest\"\x91\n" +
+	"\n" +
 	"\fInitResponse\x12\x1e\n" +
 	"\n" +
 	"showFooter\x18\x01 \x01(\bR\n" +
@@ -5206,7 +5267,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
 	"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" +
 	"\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\x12,\n" +
-	"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\"8\n" +
+	"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\x12T\n" +
+	"\x16root_dashboard_entries\x18\x1b \x03(\v2\x1e.olivetin.api.v1.RootDashboardR\x14rootDashboardEntries\"A\n" +
+	"\rRootDashboard\x12\x14\n" +
+	"\x05title\x18\x01 \x01(\tR\x05title\x12\x1a\n" +
+	"\bcategory\x18\x02 \x01(\tR\bcategory\"8\n" +
 	"\x0eAdditionalLink\x12\x14\n" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
@@ -5287,7 +5352,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 }
 
-var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 86)
+var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 87)
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*ActionGroupMembership)(nil),           // 1: olivetin.api.v1.ActionGroupMembership
@@ -5358,35 +5423,36 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*GetDiagnosticsResponse)(nil),          // 66: olivetin.api.v1.GetDiagnosticsResponse
 	(*InitRequest)(nil),                     // 67: olivetin.api.v1.InitRequest
 	(*InitResponse)(nil),                    // 68: olivetin.api.v1.InitResponse
-	(*AdditionalLink)(nil),                  // 69: olivetin.api.v1.AdditionalLink
-	(*OAuth2Provider)(nil),                  // 70: olivetin.api.v1.OAuth2Provider
-	(*GetActionBindingRequest)(nil),         // 71: olivetin.api.v1.GetActionBindingRequest
-	(*GetActionBindingResponse)(nil),        // 72: olivetin.api.v1.GetActionBindingResponse
-	(*GetEntitiesRequest)(nil),              // 73: olivetin.api.v1.GetEntitiesRequest
-	(*GetEntitiesResponse)(nil),             // 74: olivetin.api.v1.GetEntitiesResponse
-	(*EntityDefinition)(nil),                // 75: olivetin.api.v1.EntityDefinition
-	(*EntityProperty)(nil),                  // 76: olivetin.api.v1.EntityProperty
-	(*GetEntityRequest)(nil),                // 77: olivetin.api.v1.GetEntityRequest
-	(*RestartActionRequest)(nil),            // 78: olivetin.api.v1.RestartActionRequest
-	nil,                                     // 79: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	nil,                                     // 80: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
-	nil,                                     // 81: olivetin.api.v1.ActionArgument.SuggestionsEntry
-	nil,                                     // 82: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	nil,                                     // 83: olivetin.api.v1.Entity.FieldsEntry
-	nil,                                     // 84: olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	nil,                                     // 85: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	(*RootDashboard)(nil),                   // 69: olivetin.api.v1.RootDashboard
+	(*AdditionalLink)(nil),                  // 70: olivetin.api.v1.AdditionalLink
+	(*OAuth2Provider)(nil),                  // 71: olivetin.api.v1.OAuth2Provider
+	(*GetActionBindingRequest)(nil),         // 72: olivetin.api.v1.GetActionBindingRequest
+	(*GetActionBindingResponse)(nil),        // 73: olivetin.api.v1.GetActionBindingResponse
+	(*GetEntitiesRequest)(nil),              // 74: olivetin.api.v1.GetEntitiesRequest
+	(*GetEntitiesResponse)(nil),             // 75: olivetin.api.v1.GetEntitiesResponse
+	(*EntityDefinition)(nil),                // 76: olivetin.api.v1.EntityDefinition
+	(*EntityProperty)(nil),                  // 77: olivetin.api.v1.EntityProperty
+	(*GetEntityRequest)(nil),                // 78: olivetin.api.v1.GetEntityRequest
+	(*RestartActionRequest)(nil),            // 79: olivetin.api.v1.RestartActionRequest
+	nil,                                     // 80: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	nil,                                     // 81: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	nil,                                     // 82: olivetin.api.v1.ActionArgument.SuggestionsEntry
+	nil,                                     // 83: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	nil,                                     // 84: olivetin.api.v1.Entity.FieldsEntry
+	nil,                                     // 85: olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	nil,                                     // 86: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 }
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	3,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	2,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
 	1,  // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership
-	79, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	80, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	80, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	81, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
 	4,  // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
-	81, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
+	82, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
 	0,  // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action
-	82, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	83, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
+	83, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	84, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
 	5,  // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction
 	10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
 	11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
@@ -5404,8 +5470,8 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
 	22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
 	35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	84, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	85, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	85, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	86, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 	51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
 	52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
 	54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
@@ -5415,72 +5481,73 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
 	65, // 37: olivetin.api.v1.GetDiagnosticsResponse.config_issues:type_name -> olivetin.api.v1.ConfigIssue
-	70, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	69, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	71, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	70, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
 	8,  // 40: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 41: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	35, // 42: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	75, // 43: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	6,  // 44: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	76, // 45: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
-	43, // 46: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	9,  // 47: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	12, // 48: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	15, // 49: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	17, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	19, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	78, // 52: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	56, // 53: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	34, // 54: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	21, // 55: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	24, // 56: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	26, // 57: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
-	30, // 58: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	37, // 59: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	39, // 60: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
-	41, // 61: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	44, // 62: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	46, // 63: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	58, // 64: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	60, // 65: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	62, // 66: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	48, // 67: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	64, // 68: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	67, // 69: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	71, // 70: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	73, // 71: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	77, // 72: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	7,  // 73: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	14, // 74: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	16, // 75: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	18, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	20, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	14, // 78: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	57, // 79: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	36, // 80: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	23, // 81: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	25, // 82: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	29, // 83: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
-	31, // 84: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	38, // 85: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	40, // 86: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
-	42, // 87: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	45, // 88: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	47, // 89: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	59, // 90: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	61, // 91: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	63, // 92: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	49, // 93: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	66, // 94: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	68, // 95: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	72, // 96: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	74, // 97: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	6,  // 98: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	73, // [73:99] is the sub-list for method output_type
-	47, // [47:73] is the sub-list for method input_type
-	47, // [47:47] is the sub-list for extension type_name
-	47, // [47:47] is the sub-list for extension extendee
-	0,  // [0:47] is the sub-list for field type_name
+	69, // 41: olivetin.api.v1.InitResponse.root_dashboard_entries:type_name -> olivetin.api.v1.RootDashboard
+	0,  // 42: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	35, // 43: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
+	76, // 44: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	6,  // 45: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	77, // 46: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
+	43, // 47: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	9,  // 48: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	12, // 49: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	15, // 50: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	17, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	19, // 52: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	79, // 53: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	56, // 54: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	34, // 55: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	21, // 56: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	24, // 57: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	26, // 58: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
+	30, // 59: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	37, // 60: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	39, // 61: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
+	41, // 62: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	44, // 63: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	46, // 64: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	58, // 65: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	60, // 66: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	62, // 67: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	48, // 68: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	64, // 69: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	67, // 70: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	72, // 71: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	74, // 72: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	78, // 73: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	7,  // 74: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	14, // 75: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	16, // 76: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	18, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	20, // 78: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	14, // 79: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	57, // 80: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	36, // 81: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	23, // 82: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	25, // 83: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	29, // 84: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
+	31, // 85: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	38, // 86: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	40, // 87: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
+	42, // 88: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	45, // 89: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	47, // 90: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	59, // 91: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	61, // 92: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	63, // 93: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	49, // 94: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	66, // 95: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	68, // 96: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	73, // 97: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	75, // 98: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	6,  // 99: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	74, // [74:100] is the sub-list for method output_type
+	48, // [48:74] is the sub-list for method input_type
+	48, // [48:48] is the sub-list for extension type_name
+	48, // [48:48] is the sub-list for extension extendee
+	0,  // [0:48] is the sub-list for field type_name
 }
 
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
@@ -5502,7 +5569,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			NumEnums:      0,
-			NumMessages:   86,
+			NumMessages:   87,
 			NumExtensions: 0,
 			NumServices:   1,
 		},

+ 1 - 0
service/internal/config/config.go

@@ -289,6 +289,7 @@ type LogDebugOptions struct {
 
 type DashboardComponent struct {
 	Title        string                `koanf:"title"`
+	Category     string                `koanf:"category"`
 	Type         string                `koanf:"type"`
 	Entity       string                `koanf:"entity"`
 	Icon         string                `koanf:"icon"`

+ 40 - 0
service/internal/config/config_reloader.go

@@ -2,11 +2,13 @@ package config
 
 import (
 	"fmt"
+	"net"
 	"os"
 	"path/filepath"
 	"reflect"
 	"regexp"
 	"sort"
+	"strconv"
 	"strings"
 
 	"github.com/OliveTin/OliveTin/internal/configissues"
@@ -81,12 +83,50 @@ func afterLoadFinalize(cfg *Config, configPath string) {
 
 	cfg.SetDir(filepath.Dir(configPath))
 	cfg.Sanitize()
+	applyPortEnvironmentOverride(cfg)
 
 	for _, l := range listeners {
 		l()
 	}
 }
 
+// applyPortEnvironmentOverride sets the single HTTP frontend listen port from
+// $PORT when that environment variable is set. This runs after config unmarshal
+// so PORT wins over listenAddressSingleHTTPFrontend in config.yaml (common on
+// Heroku, Cloud Run, and similar hosts). When PORT is unset, the config value
+// or the default (1337) is left unchanged.
+func applyPortEnvironmentOverride(cfg *Config) {
+	envPort := strings.TrimSpace(os.Getenv("PORT"))
+	if envPort == "" {
+		return
+	}
+
+	port, err := strconv.Atoi(envPort)
+	if err != nil || port < 1 || port > 65535 {
+		log.WithFields(log.Fields{
+			"PORT":  envPort,
+			"error": err,
+		}).Error("Ignoring invalid PORT environment variable")
+		return
+	}
+
+	host := listenHostOrDefault(cfg.ListenAddressSingleHTTPFrontend)
+	cfg.ListenAddressSingleHTTPFrontend = net.JoinHostPort(host, strconv.Itoa(port))
+
+	log.WithFields(log.Fields{
+		"address": cfg.ListenAddressSingleHTTPFrontend,
+	}).Info("Using PORT environment variable for single HTTP frontend listen address")
+}
+
+func listenHostOrDefault(listenAddress string) string {
+	host, _, err := net.SplitHostPort(listenAddress)
+	if err != nil || host == "" {
+		return "0.0.0.0"
+	}
+
+	return host
+}
+
 // buildIncludePath constructs the full path to the include directory.
 func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
 	relativeIncludePath := k.String("include")

部分文件因为文件数量过多而无法显示