@muhammedaksam/easiarr 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/api/auto-setup-types.ts +62 -0
- package/src/api/bazarr-api.ts +54 -1
- package/src/api/cloudflare-api.ts +216 -17
- package/src/api/grafana-api.ts +314 -0
- package/src/api/heimdall-api.ts +209 -0
- package/src/api/homarr-api.ts +296 -0
- package/src/api/huntarr-api.ts +622 -0
- package/src/api/jellyfin-api.ts +61 -1
- package/src/api/jellyseerr-api.ts +49 -1
- package/src/api/overseerr-api.ts +489 -0
- package/src/api/plex-api.ts +329 -0
- package/src/api/portainer-api.ts +79 -1
- package/src/api/prowlarr-api.ts +44 -1
- package/src/api/qbittorrent-api.ts +57 -1
- package/src/api/tautulli-api.ts +277 -0
- package/src/api/uptime-kuma-api.ts +342 -0
- package/src/apps/registry.ts +52 -2
- package/src/config/homepage-config.ts +103 -43
- package/src/config/schema.ts +14 -0
- package/src/ui/screens/CloudflaredSetup.ts +225 -9
- package/src/ui/screens/FullAutoSetup.ts +545 -117
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grafana API Client
|
|
3
|
+
* Handles Grafana auto-setup including admin password change and Prometheus datasource
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debugLog } from "../utils/debug"
|
|
7
|
+
import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
|
|
8
|
+
|
|
9
|
+
interface GrafanaDataSource {
|
|
10
|
+
id?: number
|
|
11
|
+
uid?: string
|
|
12
|
+
orgId?: number
|
|
13
|
+
name: string
|
|
14
|
+
type: string
|
|
15
|
+
access: string
|
|
16
|
+
url: string
|
|
17
|
+
isDefault?: boolean
|
|
18
|
+
jsonData?: Record<string, unknown>
|
|
19
|
+
secureJsonData?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface GrafanaHealthResponse {
|
|
23
|
+
commit: string
|
|
24
|
+
database: string
|
|
25
|
+
version: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class GrafanaClient implements IAutoSetupClient {
|
|
29
|
+
private host: string
|
|
30
|
+
private port: number
|
|
31
|
+
private username: string
|
|
32
|
+
private password: string
|
|
33
|
+
|
|
34
|
+
constructor(host: string, port: number = 3000, username: string = "admin", password: string = "admin") {
|
|
35
|
+
this.host = host
|
|
36
|
+
this.port = port
|
|
37
|
+
this.username = username
|
|
38
|
+
this.password = password
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Get base URL for Grafana
|
|
43
|
+
*/
|
|
44
|
+
private get baseUrl(): string {
|
|
45
|
+
return `http://${this.host}:${this.port}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get Basic Auth header
|
|
50
|
+
*/
|
|
51
|
+
private getAuthHeader(): string {
|
|
52
|
+
const credentials = Buffer.from(`${this.username}:${this.password}`).toString("base64")
|
|
53
|
+
return `Basic ${credentials}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Common headers for Grafana API requests
|
|
58
|
+
*/
|
|
59
|
+
private getHeaders(): Record<string, string> {
|
|
60
|
+
return {
|
|
61
|
+
"Content-Type": "application/json",
|
|
62
|
+
Accept: "application/json",
|
|
63
|
+
Authorization: this.getAuthHeader(),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Update credentials (after password change)
|
|
69
|
+
*/
|
|
70
|
+
setCredentials(username: string, password: string): void {
|
|
71
|
+
this.username = username
|
|
72
|
+
this.password = password
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check if Grafana is reachable
|
|
77
|
+
*/
|
|
78
|
+
async isHealthy(): Promise<boolean> {
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch(`${this.baseUrl}/api/health`, {
|
|
81
|
+
method: "GET",
|
|
82
|
+
})
|
|
83
|
+
debugLog("GrafanaApi", `Health check: ${response.status}`)
|
|
84
|
+
return response.ok
|
|
85
|
+
} catch (error) {
|
|
86
|
+
debugLog("GrafanaApi", `Health check failed: ${error}`)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if Grafana is already configured (has non-default password)
|
|
93
|
+
*/
|
|
94
|
+
async isInitialized(): Promise<boolean> {
|
|
95
|
+
try {
|
|
96
|
+
// Try to login with default credentials
|
|
97
|
+
const response = await fetch(`${this.baseUrl}/api/user`, {
|
|
98
|
+
method: "GET",
|
|
99
|
+
headers: {
|
|
100
|
+
Authorization: `Basic ${Buffer.from("admin:admin").toString("base64")}`,
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
// If login with admin:admin fails, it's already configured
|
|
104
|
+
return !response.ok
|
|
105
|
+
} catch {
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Change admin password
|
|
112
|
+
*/
|
|
113
|
+
async changeAdminPassword(newPassword: string): Promise<boolean> {
|
|
114
|
+
debugLog("GrafanaApi", "Changing admin password...")
|
|
115
|
+
|
|
116
|
+
const response = await fetch(`${this.baseUrl}/api/user/password`, {
|
|
117
|
+
method: "PUT",
|
|
118
|
+
headers: this.getHeaders(),
|
|
119
|
+
body: JSON.stringify({
|
|
120
|
+
oldPassword: this.password,
|
|
121
|
+
newPassword: newPassword,
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
if (response.ok) {
|
|
126
|
+
debugLog("GrafanaApi", "Admin password changed successfully")
|
|
127
|
+
this.password = newPassword
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const text = await response.text()
|
|
132
|
+
debugLog("GrafanaApi", `Failed to change password: ${response.status} - ${text}`)
|
|
133
|
+
return false
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Get list of datasources
|
|
138
|
+
*/
|
|
139
|
+
async getDataSources(): Promise<GrafanaDataSource[]> {
|
|
140
|
+
const response = await fetch(`${this.baseUrl}/api/datasources`, {
|
|
141
|
+
method: "GET",
|
|
142
|
+
headers: this.getHeaders(),
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
throw new Error(`Failed to get datasources: ${response.status}`)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return response.json()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if a datasource with the given name exists
|
|
154
|
+
*/
|
|
155
|
+
async dataSourceExists(name: string): Promise<boolean> {
|
|
156
|
+
const dataSources = await this.getDataSources()
|
|
157
|
+
return dataSources.some((ds) => ds.name === name)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Create a Prometheus datasource
|
|
162
|
+
*/
|
|
163
|
+
async createPrometheusDataSource(
|
|
164
|
+
name: string = "Prometheus",
|
|
165
|
+
url: string = "http://prometheus:9090",
|
|
166
|
+
isDefault: boolean = true
|
|
167
|
+
): Promise<boolean> {
|
|
168
|
+
debugLog("GrafanaApi", `Creating Prometheus datasource: ${name} -> ${url}`)
|
|
169
|
+
|
|
170
|
+
const payload: GrafanaDataSource = {
|
|
171
|
+
name,
|
|
172
|
+
type: "prometheus",
|
|
173
|
+
access: "proxy",
|
|
174
|
+
url,
|
|
175
|
+
isDefault,
|
|
176
|
+
jsonData: {
|
|
177
|
+
httpMethod: "POST",
|
|
178
|
+
timeInterval: "15s",
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const response = await fetch(`${this.baseUrl}/api/datasources`, {
|
|
183
|
+
method: "POST",
|
|
184
|
+
headers: this.getHeaders(),
|
|
185
|
+
body: JSON.stringify(payload),
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
if (response.ok) {
|
|
189
|
+
debugLog("GrafanaApi", `Prometheus datasource "${name}" created successfully`)
|
|
190
|
+
return true
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Check if already exists (409 Conflict)
|
|
194
|
+
if (response.status === 409) {
|
|
195
|
+
debugLog("GrafanaApi", `Datasource "${name}" already exists`)
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const text = await response.text()
|
|
200
|
+
debugLog("GrafanaApi", `Failed to create datasource: ${response.status} - ${text}`)
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate an API key for external integrations
|
|
206
|
+
*/
|
|
207
|
+
async createApiKey(name: string = "easiarr", role: string = "Admin"): Promise<string | null> {
|
|
208
|
+
debugLog("GrafanaApi", `Creating API key: ${name}`)
|
|
209
|
+
|
|
210
|
+
const response = await fetch(`${this.baseUrl}/api/auth/keys`, {
|
|
211
|
+
method: "POST",
|
|
212
|
+
headers: this.getHeaders(),
|
|
213
|
+
body: JSON.stringify({
|
|
214
|
+
name,
|
|
215
|
+
role,
|
|
216
|
+
secondsToLive: 0, // Never expires
|
|
217
|
+
}),
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
if (response.ok) {
|
|
221
|
+
const data = await response.json()
|
|
222
|
+
debugLog("GrafanaApi", "API key created successfully")
|
|
223
|
+
return data.key
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// May already exist
|
|
227
|
+
if (response.status === 409) {
|
|
228
|
+
debugLog("GrafanaApi", "API key already exists")
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const text = await response.text()
|
|
233
|
+
debugLog("GrafanaApi", `Failed to create API key: ${response.status} - ${text}`)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get Grafana server info
|
|
239
|
+
*/
|
|
240
|
+
async getServerInfo(): Promise<GrafanaHealthResponse | null> {
|
|
241
|
+
try {
|
|
242
|
+
const response = await fetch(`${this.baseUrl}/api/health`, {
|
|
243
|
+
method: "GET",
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
if (response.ok) {
|
|
247
|
+
return response.json()
|
|
248
|
+
}
|
|
249
|
+
} catch {
|
|
250
|
+
// Ignore
|
|
251
|
+
}
|
|
252
|
+
return null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Run the auto-setup process for Grafana
|
|
257
|
+
*/
|
|
258
|
+
async setup(options: AutoSetupOptions): Promise<AutoSetupResult> {
|
|
259
|
+
const { username, password } = options
|
|
260
|
+
|
|
261
|
+
try {
|
|
262
|
+
// Check if reachable
|
|
263
|
+
const healthy = await this.isHealthy()
|
|
264
|
+
if (!healthy) {
|
|
265
|
+
return { success: false, message: "Grafana not reachable" }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Check if already configured
|
|
269
|
+
const initialized = await this.isInitialized()
|
|
270
|
+
|
|
271
|
+
if (!initialized) {
|
|
272
|
+
// First login - change default password
|
|
273
|
+
this.setCredentials("admin", "admin")
|
|
274
|
+
|
|
275
|
+
const changed = await this.changeAdminPassword(password)
|
|
276
|
+
if (!changed) {
|
|
277
|
+
return { success: false, message: "Failed to change admin password" }
|
|
278
|
+
}
|
|
279
|
+
} else {
|
|
280
|
+
// Try to login with provided credentials
|
|
281
|
+
this.setCredentials(username, password)
|
|
282
|
+
|
|
283
|
+
// Verify login by fetching user
|
|
284
|
+
const response = await fetch(`${this.baseUrl}/api/user`, {
|
|
285
|
+
method: "GET",
|
|
286
|
+
headers: this.getHeaders(),
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
return { success: false, message: "Login failed - check credentials" }
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Now configure Prometheus datasource if prometheus is enabled
|
|
295
|
+
// Use container name for internal communication
|
|
296
|
+
const prometheusExists = await this.dataSourceExists("Prometheus")
|
|
297
|
+
if (!prometheusExists) {
|
|
298
|
+
await this.createPrometheusDataSource("Prometheus", "http://prometheus:9090", true)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Generate API key for Homepage widget etc.
|
|
302
|
+
const apiKey = await this.createApiKey("easiarr-api-key")
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
success: true,
|
|
306
|
+
message: initialized ? "Configured" : "Password changed, Prometheus added",
|
|
307
|
+
data: apiKey ? { apiKey } : undefined,
|
|
308
|
+
envUpdates: apiKey ? { API_KEY_GRAFANA: apiKey } : undefined,
|
|
309
|
+
}
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return { success: false, message: `${error}` }
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heimdall API Client
|
|
3
|
+
* Handles Heimdall dashboard auto-setup with application tiles
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { debugLog } from "../utils/debug"
|
|
7
|
+
import type { IAutoSetupClient, AutoSetupOptions, AutoSetupResult } from "./auto-setup-types"
|
|
8
|
+
import type { AppConfig } from "../config/schema"
|
|
9
|
+
import { getApp } from "../apps/registry"
|
|
10
|
+
|
|
11
|
+
interface HeimdallApp {
|
|
12
|
+
id?: number
|
|
13
|
+
title: string
|
|
14
|
+
url: string
|
|
15
|
+
colour?: string
|
|
16
|
+
icon?: string
|
|
17
|
+
appdescription?: string
|
|
18
|
+
pinned?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class HeimdallClient implements IAutoSetupClient {
|
|
22
|
+
private host: string
|
|
23
|
+
private port: number
|
|
24
|
+
|
|
25
|
+
constructor(host: string, port: number = 80) {
|
|
26
|
+
this.host = host
|
|
27
|
+
this.port = port
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get base URL for Heimdall
|
|
32
|
+
*/
|
|
33
|
+
private get baseUrl(): string {
|
|
34
|
+
return `http://${this.host}:${this.port}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Check if Heimdall is reachable
|
|
39
|
+
*/
|
|
40
|
+
async isHealthy(): Promise<boolean> {
|
|
41
|
+
try {
|
|
42
|
+
const response = await fetch(this.baseUrl, {
|
|
43
|
+
method: "GET",
|
|
44
|
+
})
|
|
45
|
+
debugLog("HeimdallApi", `Health check: ${response.status}`)
|
|
46
|
+
return response.ok
|
|
47
|
+
} catch (error) {
|
|
48
|
+
debugLog("HeimdallApi", `Health check failed: ${error}`)
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Check if already configured
|
|
55
|
+
*/
|
|
56
|
+
async isInitialized(): Promise<boolean> {
|
|
57
|
+
// Heimdall is always "initialized" - it works out of the box
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get list of apps (via API if available)
|
|
63
|
+
* Note: Heimdall primarily uses web UI for configuration
|
|
64
|
+
*/
|
|
65
|
+
async getApps(): Promise<HeimdallApp[]> {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetch(`${this.baseUrl}/api/items`, {
|
|
68
|
+
method: "GET",
|
|
69
|
+
headers: {
|
|
70
|
+
Accept: "application/json",
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
if (response.ok) {
|
|
75
|
+
return response.json()
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// API may not be available or require auth
|
|
79
|
+
}
|
|
80
|
+
return []
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Add an app/tile to Heimdall
|
|
85
|
+
* Note: Heimdall API may require authentication
|
|
86
|
+
*/
|
|
87
|
+
async addApp(app: HeimdallApp): Promise<boolean> {
|
|
88
|
+
debugLog("HeimdallApi", `Adding app: ${app.title}`)
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
const response = await fetch(`${this.baseUrl}/api/items`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Accept: "application/json",
|
|
96
|
+
},
|
|
97
|
+
body: JSON.stringify(app),
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
if (response.ok) {
|
|
101
|
+
debugLog("HeimdallApi", `App "${app.title}" added successfully`)
|
|
102
|
+
return true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// API might require auth or not exist
|
|
106
|
+
if (response.status === 401 || response.status === 403) {
|
|
107
|
+
debugLog("HeimdallApi", "API requires authentication")
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (response.status === 404) {
|
|
112
|
+
debugLog("HeimdallApi", "Items API not available")
|
|
113
|
+
return false
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const text = await response.text()
|
|
117
|
+
debugLog("HeimdallApi", `Failed to add app: ${response.status} - ${text}`)
|
|
118
|
+
return false
|
|
119
|
+
} catch (error) {
|
|
120
|
+
debugLog("HeimdallApi", `Failed to add app: ${error}`)
|
|
121
|
+
return false
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build app config for an easiarr app
|
|
127
|
+
*/
|
|
128
|
+
buildAppConfig(appConfig: AppConfig): HeimdallApp | null {
|
|
129
|
+
const appDef = getApp(appConfig.id)
|
|
130
|
+
if (!appDef) return null
|
|
131
|
+
|
|
132
|
+
// Skip apps without web UI
|
|
133
|
+
if (appDef.defaultPort === 0) return null
|
|
134
|
+
|
|
135
|
+
const port = appConfig.port || appDef.defaultPort
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
title: appDef.name,
|
|
139
|
+
url: `http://${appConfig.id}:${port}`,
|
|
140
|
+
appdescription: appDef.description,
|
|
141
|
+
pinned: true,
|
|
142
|
+
colour: this.getColorForCategory(appDef.category),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Get a color based on app category
|
|
148
|
+
*/
|
|
149
|
+
private getColorForCategory(category: string): string {
|
|
150
|
+
const colors: Record<string, string> = {
|
|
151
|
+
servarr: "#ffc107",
|
|
152
|
+
indexer: "#17a2b8",
|
|
153
|
+
downloader: "#28a745",
|
|
154
|
+
mediaserver: "#6c5ce7",
|
|
155
|
+
request: "#e17055",
|
|
156
|
+
monitoring: "#00cec9",
|
|
157
|
+
infrastructure: "#636e72",
|
|
158
|
+
vpn: "#fd79a8",
|
|
159
|
+
utility: "#74b9ff",
|
|
160
|
+
}
|
|
161
|
+
return colors[category] || "#6c757d"
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Run the auto-setup process for Heimdall
|
|
166
|
+
*/
|
|
167
|
+
async setup(_options: AutoSetupOptions): Promise<AutoSetupResult> {
|
|
168
|
+
try {
|
|
169
|
+
// Check if reachable
|
|
170
|
+
const healthy = await this.isHealthy()
|
|
171
|
+
if (!healthy) {
|
|
172
|
+
return { success: false, message: "Heimdall not reachable" }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Check existing apps count
|
|
176
|
+
const existingApps = await this.getApps()
|
|
177
|
+
|
|
178
|
+
// Heimdall works out of the box, tiles can be added via UI
|
|
179
|
+
return {
|
|
180
|
+
success: true,
|
|
181
|
+
message: "Ready - add tiles via UI",
|
|
182
|
+
data: { existingAppsCount: existingApps.length },
|
|
183
|
+
}
|
|
184
|
+
} catch (error) {
|
|
185
|
+
return { success: false, message: `${error}` }
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Auto-add apps for enabled easiarr services
|
|
191
|
+
*/
|
|
192
|
+
async setupEasiarrApps(apps: AppConfig[]): Promise<number> {
|
|
193
|
+
let addedCount = 0
|
|
194
|
+
|
|
195
|
+
for (const appConfig of apps) {
|
|
196
|
+
if (!appConfig.enabled) continue
|
|
197
|
+
|
|
198
|
+
const heimdallApp = this.buildAppConfig(appConfig)
|
|
199
|
+
if (!heimdallApp) continue
|
|
200
|
+
|
|
201
|
+
const success = await this.addApp(heimdallApp)
|
|
202
|
+
if (success) {
|
|
203
|
+
addedCount++
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return addedCount
|
|
208
|
+
}
|
|
209
|
+
}
|