@muhammedaksam/easiarr 0.10.0 → 1.1.0

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.
@@ -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
+ }