@davidfuchs/mcp-uptime-kuma 0.6.4 → 0.9.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.
@@ -26,8 +26,14 @@ export class UptimeKumaClient {
26
26
  heartbeatListCache = {};
27
27
  uptimeCache = {};
28
28
  avgPingCache = {};
29
+ notificationListCache = {};
30
+ tagListCache = [];
31
+ maintenanceListCache = {};
32
+ statusPageListCache = {};
33
+ dockerHostListCache = [];
29
34
  server;
30
35
  shouldLog;
36
+ loginCredentials = null;
31
37
  constructor(url, server, shouldLog) {
32
38
  this.url = url;
33
39
  this.server = server;
@@ -55,18 +61,69 @@ export class UptimeKumaClient {
55
61
  this.socket = io(this.url, {
56
62
  reconnection: true,
57
63
  reconnectionDelay: 1000,
58
- reconnectionAttempts: 5,
64
+ reconnectionAttempts: Infinity,
59
65
  });
66
+ let initialConnect = true;
60
67
  this.socket.on('connect', () => {
61
- this.safeLog('info', 'Successfully connected to Uptime Kuma server');
62
- resolve();
68
+ if (initialConnect) {
69
+ initialConnect = false;
70
+ this.safeLog('info', 'Successfully connected to Uptime Kuma server');
71
+ resolve();
72
+ }
73
+ else {
74
+ this.safeLog('info', 'Reconnected to Uptime Kuma server, re-authenticating...');
75
+ this.reauthenticate();
76
+ }
63
77
  });
64
78
  this.socket.on('connect_error', (error) => {
65
79
  this.safeLog('error', `Connection error: ${error.message}`);
66
- reject(new Error(`Connection failed: ${error.message}`));
80
+ if (initialConnect) {
81
+ reject(new Error(`Connection failed: ${error.message}`));
82
+ }
67
83
  });
68
84
  });
69
85
  }
86
+ /**
87
+ * Re-authenticate after a reconnection to refresh all cached data.
88
+ * When the server restarts or the connection drops, Socket.IO reconnects
89
+ * the transport but the server no longer considers the client authenticated.
90
+ * Without re-emitting login, the server won't send monitorList or heartbeat
91
+ * events, leaving the cache permanently stale.
92
+ */
93
+ reauthenticate() {
94
+ if (!this.socket || !this.loginCredentials)
95
+ return;
96
+ // Clear stale caches so they are fully replaced by fresh data from the server
97
+ this.monitorListCache = {};
98
+ this.heartbeatListCache = {};
99
+ this.uptimeCache = {};
100
+ this.avgPingCache = {};
101
+ const { username, password, token, jwtToken } = this.loginCredentials;
102
+ if (jwtToken) {
103
+ this.socket.emit('loginByToken', jwtToken, (response) => {
104
+ if (response.ok) {
105
+ this.safeLog('info', 'Re-authenticated after reconnection (JWT)');
106
+ }
107
+ else {
108
+ this.safeLog('error', `Re-authentication failed: ${response.msg || 'unknown error'}`);
109
+ }
110
+ });
111
+ }
112
+ else if (username) {
113
+ this.socket.emit('login', { username, password, token }, (response) => {
114
+ if (response.ok) {
115
+ this.safeLog('info', 'Re-authenticated after reconnection');
116
+ }
117
+ else {
118
+ this.safeLog('error', `Re-authentication failed: ${response.msg || 'unknown error'}`);
119
+ }
120
+ });
121
+ }
122
+ else {
123
+ this.socket.emit('login');
124
+ this.safeLog('info', 'Re-authenticated after reconnection (anonymous)');
125
+ }
126
+ }
70
127
  /**
71
128
  * Disconnect from the Uptime Kuma server
72
129
  */
@@ -78,6 +135,11 @@ export class UptimeKumaClient {
78
135
  this.socket.off('deleteMonitorFromList');
79
136
  this.socket.off('heartbeatList');
80
137
  this.socket.off('heartbeat');
138
+ this.socket.off('notificationList');
139
+ this.socket.off('tagList');
140
+ this.socket.off('maintenanceList');
141
+ this.socket.off('statusPageList');
142
+ this.socket.off('dockerHostList');
81
143
  this.socket.disconnect();
82
144
  this.socket = null;
83
145
  }
@@ -86,6 +148,11 @@ export class UptimeKumaClient {
86
148
  this.heartbeatListCache = {};
87
149
  this.uptimeCache = {};
88
150
  this.avgPingCache = {};
151
+ this.notificationListCache = {};
152
+ this.tagListCache = [];
153
+ this.maintenanceListCache = {};
154
+ this.statusPageListCache = {};
155
+ this.dockerHostListCache = [];
89
156
  }
90
157
  /**
91
158
  * Login using username and password, or JWT token
@@ -102,11 +169,18 @@ export class UptimeKumaClient {
102
169
  reject(new Error('Not connected to server'));
103
170
  return;
104
171
  }
172
+ // Store credentials for re-authentication on reconnect
173
+ this.loginCredentials = { username, password, token, jwtToken };
105
174
  // Set up listeners for monitor list and heartbeat updates before login
106
175
  this.setupMonitorListListeners();
107
176
  this.setupHeartbeatListeners();
108
177
  this.setupUptimeListeners();
109
178
  this.setupAvgPingListeners();
179
+ this.setupNotificationListListeners();
180
+ this.setupTagListListeners();
181
+ this.setupMaintenanceListListeners();
182
+ this.setupStatusPageListListeners();
183
+ this.setupDockerHostListListeners();
110
184
  // If JWT token is provided, use token-based authentication
111
185
  if (jwtToken) {
112
186
  this.socket.emit('loginByToken', jwtToken, (response) => {
@@ -528,6 +602,565 @@ export class UptimeKumaClient {
528
602
  }
529
603
  return summaries;
530
604
  }
605
+ // ─── New listener setup methods ────────────────────────────────────────────
606
+ setupNotificationListListeners() {
607
+ if (!this.socket)
608
+ return;
609
+ this.socket.on('notificationList', (notificationList) => {
610
+ this.safeLog('debug', `Received notificationList with ${Object.keys(notificationList).length} notifications`);
611
+ this.notificationListCache = notificationList;
612
+ });
613
+ }
614
+ setupTagListListeners() {
615
+ if (!this.socket)
616
+ return;
617
+ this.socket.on('tagList', (tagList) => {
618
+ this.safeLog('debug', `Received tagList with ${tagList.length} tags`);
619
+ this.tagListCache = tagList;
620
+ });
621
+ }
622
+ setupMaintenanceListListeners() {
623
+ if (!this.socket)
624
+ return;
625
+ this.socket.on('maintenanceList', (maintenanceList) => {
626
+ this.safeLog('debug', `Received maintenanceList with ${Object.keys(maintenanceList).length} windows`);
627
+ this.maintenanceListCache = maintenanceList;
628
+ });
629
+ }
630
+ setupStatusPageListListeners() {
631
+ if (!this.socket)
632
+ return;
633
+ this.socket.on('statusPageList', (statusPageList) => {
634
+ this.safeLog('debug', `Received statusPageList with ${Object.keys(statusPageList).length} status pages`);
635
+ this.statusPageListCache = statusPageList;
636
+ });
637
+ }
638
+ setupDockerHostListListeners() {
639
+ if (!this.socket)
640
+ return;
641
+ this.socket.on('dockerHostList', (dockerHostList) => {
642
+ this.safeLog('debug', `Received dockerHostList with ${dockerHostList.length} docker hosts`);
643
+ this.dockerHostListCache = dockerHostList;
644
+ });
645
+ }
646
+ // ─── Monitor write operations ───────────────────────────────────────────────
647
+ /**
648
+ * Create a new monitor. If `tags` are supplied, they are applied after
649
+ * creation using the separate `addMonitorTag` socket event — the `add`
650
+ * socket handler does not process tags.
651
+ *
652
+ * @param monitorData - Monitor configuration (type-specific fields should be included)
653
+ * @returns Promise resolving to the API response with the new monitorID
654
+ */
655
+ async createMonitor(monitorData) {
656
+ const { tags, ...payload } = monitorData;
657
+ const response = await new Promise((resolve, reject) => {
658
+ if (!this.socket || !this.socket.connected) {
659
+ reject(new Error('Not connected to server'));
660
+ return;
661
+ }
662
+ this.socket.emit('add', payload, (res) => {
663
+ if (res.ok) {
664
+ this.safeLog('info', `Successfully created monitor (ID: ${res.monitorID})`);
665
+ resolve(res);
666
+ }
667
+ else {
668
+ reject(new Error(res.msg || 'Failed to create monitor'));
669
+ }
670
+ });
671
+ });
672
+ if (tags && Array.isArray(tags) && response.monitorID != null) {
673
+ await this.reconcileMonitorTags(response.monitorID, tags);
674
+ }
675
+ return response;
676
+ }
677
+ /**
678
+ * Update an existing monitor. If `tags` are supplied, they are reconciled
679
+ * against the monitor's current tag set using `addMonitorTag` /
680
+ * `deleteMonitorTag` — the `editMonitor` socket handler does not process
681
+ * tags. Tags whose name is not yet in the catalog are auto-created via
682
+ * `addTag` before binding.
683
+ *
684
+ * @param monitorData - Monitor configuration including the id field
685
+ * @returns Promise resolving to the API response
686
+ */
687
+ async updateMonitor(monitorData) {
688
+ const { tags, ...payload } = monitorData;
689
+ const response = await new Promise((resolve, reject) => {
690
+ if (!this.socket || !this.socket.connected) {
691
+ reject(new Error('Not connected to server'));
692
+ return;
693
+ }
694
+ this.socket.emit('editMonitor', payload, (res) => {
695
+ if (res.ok) {
696
+ this.safeLog('info', `Successfully updated monitor (ID: ${monitorData['id']})`);
697
+ resolve(res);
698
+ }
699
+ else {
700
+ reject(new Error(res.msg || 'Failed to update monitor'));
701
+ }
702
+ });
703
+ });
704
+ if (tags && Array.isArray(tags) && monitorData['id'] != null) {
705
+ await this.reconcileMonitorTags(Number(monitorData['id']), tags);
706
+ }
707
+ return response;
708
+ }
709
+ /**
710
+ * Fetch the tag catalog synchronously from the server. Uptime Kuma does
711
+ * not push `tagList` events — it only responds to the `getTags` request —
712
+ * so relying on the push-populated cache returns stale (often empty) data.
713
+ * This method also refreshes `tagListCache` so subsequent cache reads work.
714
+ */
715
+ fetchTagList() {
716
+ return new Promise((resolve, reject) => {
717
+ if (!this.socket || !this.socket.connected) {
718
+ reject(new Error('Not connected to server'));
719
+ return;
720
+ }
721
+ this.socket.emit('getTags', (res) => {
722
+ if (res.ok && res.tags) {
723
+ this.tagListCache = res.tags;
724
+ resolve(res.tags);
725
+ }
726
+ else {
727
+ reject(new Error(res.msg || 'Failed to fetch tag list'));
728
+ }
729
+ });
730
+ });
731
+ }
732
+ /**
733
+ * Bind a tag to a monitor (socket: `addMonitorTag`).
734
+ */
735
+ addMonitorTag(tagID, monitorID, value) {
736
+ return new Promise((resolve, reject) => {
737
+ if (!this.socket || !this.socket.connected) {
738
+ reject(new Error('Not connected to server'));
739
+ return;
740
+ }
741
+ this.socket.emit('addMonitorTag', tagID, monitorID, value, (res) => {
742
+ if (res.ok)
743
+ resolve(res);
744
+ else
745
+ reject(new Error(res.msg || `Failed to add tag ${tagID} to monitor ${monitorID}`));
746
+ });
747
+ });
748
+ }
749
+ /**
750
+ * Unbind a tag from a monitor (socket: `deleteMonitorTag`).
751
+ */
752
+ deleteMonitorTag(tagID, monitorID, value) {
753
+ return new Promise((resolve, reject) => {
754
+ if (!this.socket || !this.socket.connected) {
755
+ reject(new Error('Not connected to server'));
756
+ return;
757
+ }
758
+ this.socket.emit('deleteMonitorTag', tagID, monitorID, value, (res) => {
759
+ if (res.ok)
760
+ resolve(res);
761
+ else
762
+ reject(new Error(res.msg || `Failed to remove tag ${tagID} from monitor ${monitorID}`));
763
+ });
764
+ });
765
+ }
766
+ /**
767
+ * Reconcile a monitor's tags against the desired list. Auto-creates any
768
+ * tag name that isn't yet in the catalog. Tag identity is `(name, value)`.
769
+ */
770
+ async reconcileMonitorTags(monitorID, desiredTags) {
771
+ const currentMonitor = this.monitorListCache[String(monitorID)];
772
+ const currentTags = (currentMonitor?.tags ?? []);
773
+ const key = (name, value) => `${name}\u0000${value ?? ''}`;
774
+ const currentKeys = new Set(currentTags.map((t) => key(t.name, t.value)));
775
+ const desiredKeys = new Set(desiredTags.map((t) => key(String(t.name), t.value)));
776
+ // Uptime Kuma never pushes `tagList` events, so the cache is unreliable
777
+ // — fetch the catalog synchronously via the `getTags` socket request.
778
+ const freshTags = await this.fetchTagList();
779
+ const nameToID = new Map();
780
+ for (const t of freshTags)
781
+ nameToID.set(t.name, t.id);
782
+ for (const desired of desiredTags) {
783
+ const name = String(desired.name);
784
+ const value = desired.value ?? '';
785
+ if (currentKeys.has(key(name, value)))
786
+ continue;
787
+ let tagID = nameToID.get(name);
788
+ if (tagID == null) {
789
+ const color = desired.color ?? '#808080';
790
+ const created = await this.addTag(name, color);
791
+ tagID = created.tag?.id;
792
+ if (tagID != null)
793
+ nameToID.set(name, tagID);
794
+ }
795
+ if (tagID == null) {
796
+ throw new Error(`Could not resolve tag ID for "${name}"`);
797
+ }
798
+ await this.addMonitorTag(tagID, monitorID, value);
799
+ }
800
+ for (const existing of currentTags) {
801
+ if (desiredKeys.has(key(existing.name, existing.value)))
802
+ continue;
803
+ const tagID = existing.tag_id ?? nameToID.get(existing.name);
804
+ if (tagID == null)
805
+ continue;
806
+ await this.deleteMonitorTag(tagID, monitorID, existing.value ?? '');
807
+ }
808
+ }
809
+ /**
810
+ * Delete a monitor
811
+ *
812
+ * @param monitorID - The ID of the monitor to delete
813
+ * @returns Promise resolving to the API response
814
+ */
815
+ deleteMonitor(monitorID) {
816
+ return new Promise((resolve, reject) => {
817
+ if (!this.socket || !this.socket.connected) {
818
+ reject(new Error('Not connected to server'));
819
+ return;
820
+ }
821
+ this.socket.emit('deleteMonitor', monitorID, (response) => {
822
+ if (response.ok) {
823
+ this.safeLog('info', `Successfully deleted monitor ${monitorID}`);
824
+ resolve(response);
825
+ }
826
+ else {
827
+ reject(new Error(response.msg || 'Failed to delete monitor'));
828
+ }
829
+ });
830
+ });
831
+ }
832
+ // ─── Notification operations ────────────────────────────────────────────────
833
+ /**
834
+ * Get the cached notification list
835
+ */
836
+ getNotificationList() {
837
+ return Object.values(this.notificationListCache);
838
+ }
839
+ /**
840
+ * Add or update a notification channel
841
+ *
842
+ * @param notification - Notification configuration
843
+ * @param notificationID - If provided, updates existing; otherwise creates new
844
+ * @returns Promise resolving to the API response with the notification id
845
+ */
846
+ addNotification(notification, notificationID) {
847
+ return new Promise((resolve, reject) => {
848
+ if (!this.socket || !this.socket.connected) {
849
+ reject(new Error('Not connected to server'));
850
+ return;
851
+ }
852
+ const id = notificationID ?? null;
853
+ this.socket.emit('addNotification', notification, id, (response) => {
854
+ if (response.ok) {
855
+ this.safeLog('info', `Successfully saved notification (ID: ${response.id})`);
856
+ resolve(response);
857
+ }
858
+ else {
859
+ reject(new Error(response.msg || 'Failed to save notification'));
860
+ }
861
+ });
862
+ });
863
+ }
864
+ /**
865
+ * Delete a notification channel
866
+ *
867
+ * @param notificationID - The ID of the notification to delete
868
+ * @returns Promise resolving to the API response
869
+ */
870
+ deleteNotification(notificationID) {
871
+ return new Promise((resolve, reject) => {
872
+ if (!this.socket || !this.socket.connected) {
873
+ reject(new Error('Not connected to server'));
874
+ return;
875
+ }
876
+ this.socket.emit('deleteNotification', notificationID, (response) => {
877
+ if (response.ok) {
878
+ this.safeLog('info', `Successfully deleted notification ${notificationID}`);
879
+ resolve(response);
880
+ }
881
+ else {
882
+ reject(new Error(response.msg || 'Failed to delete notification'));
883
+ }
884
+ });
885
+ });
886
+ }
887
+ // ─── Docker host operations ─────────────────────────────────────────────────
888
+ /**
889
+ * Get the cached docker host list
890
+ */
891
+ getDockerHostList() {
892
+ return this.dockerHostListCache;
893
+ }
894
+ /**
895
+ * Add or update a docker host
896
+ *
897
+ * @param dockerHost - Docker host configuration (name, dockerType, dockerDaemon)
898
+ * @param dockerHostID - If provided, updates existing; otherwise creates new
899
+ * @returns Promise resolving to the API response with the docker host id
900
+ */
901
+ addDockerHost(dockerHost, dockerHostID) {
902
+ return new Promise((resolve, reject) => {
903
+ if (!this.socket || !this.socket.connected) {
904
+ reject(new Error('Not connected to server'));
905
+ return;
906
+ }
907
+ const id = dockerHostID ?? null;
908
+ this.socket.emit('addDockerHost', dockerHost, id, (response) => {
909
+ if (response.ok) {
910
+ this.safeLog('info', `Successfully saved docker host (ID: ${response.id})`);
911
+ resolve(response);
912
+ }
913
+ else {
914
+ reject(new Error(response.msg || 'Failed to save docker host'));
915
+ }
916
+ });
917
+ });
918
+ }
919
+ /**
920
+ * Delete a docker host. Any monitors referencing it will have their docker_host
921
+ * field cleared by Uptime Kuma.
922
+ *
923
+ * @param dockerHostID - The ID of the docker host to delete
924
+ * @returns Promise resolving to the API response
925
+ */
926
+ deleteDockerHost(dockerHostID) {
927
+ return new Promise((resolve, reject) => {
928
+ if (!this.socket || !this.socket.connected) {
929
+ reject(new Error('Not connected to server'));
930
+ return;
931
+ }
932
+ this.socket.emit('deleteDockerHost', dockerHostID, (response) => {
933
+ if (response.ok) {
934
+ this.safeLog('info', `Successfully deleted docker host ${dockerHostID}`);
935
+ resolve(response);
936
+ }
937
+ else {
938
+ reject(new Error(response.msg || 'Failed to delete docker host'));
939
+ }
940
+ });
941
+ });
942
+ }
943
+ /**
944
+ * Test connectivity to a docker host without persisting it. Returns a friendly
945
+ * message containing the number of containers when reachable.
946
+ *
947
+ * @param dockerHost - Docker host configuration to test (name, dockerType, dockerDaemon)
948
+ * @returns Promise resolving to the API response
949
+ */
950
+ testDockerHost(dockerHost) {
951
+ return new Promise((resolve, reject) => {
952
+ if (!this.socket || !this.socket.connected) {
953
+ reject(new Error('Not connected to server'));
954
+ return;
955
+ }
956
+ this.socket.emit('testDockerHost', dockerHost, (response) => {
957
+ // Resolve either way so callers can inspect ok/msg without try/catch
958
+ // (matches the pattern used by UK's UI, which shows both success and
959
+ // failure messages from the same callback).
960
+ resolve(response);
961
+ });
962
+ });
963
+ }
964
+ // ─── Tag operations ─────────────────────────────────────────────────────────
965
+ /**
966
+ * Get the cached tag list
967
+ */
968
+ getTagList() {
969
+ return this.tagListCache;
970
+ }
971
+ /**
972
+ * Create a new tag
973
+ *
974
+ * @param name - Tag name
975
+ * @param color - Tag color (hex string, e.g. '#ff0000')
976
+ * @returns Promise resolving to the created tag object
977
+ */
978
+ addTag(name, color) {
979
+ return new Promise((resolve, reject) => {
980
+ if (!this.socket || !this.socket.connected) {
981
+ reject(new Error('Not connected to server'));
982
+ return;
983
+ }
984
+ this.socket.emit('addTag', { name, color }, (response) => {
985
+ if (response.ok) {
986
+ this.safeLog('info', `Successfully created tag "${name}" (ID: ${response.tag?.id})`);
987
+ resolve(response);
988
+ }
989
+ else {
990
+ reject(new Error(response.msg || 'Failed to create tag'));
991
+ }
992
+ });
993
+ });
994
+ }
995
+ /**
996
+ * Delete a tag
997
+ *
998
+ * @param tagID - The ID of the tag to delete
999
+ * @returns Promise resolving to the API response
1000
+ */
1001
+ deleteTag(tagID) {
1002
+ return new Promise((resolve, reject) => {
1003
+ if (!this.socket || !this.socket.connected) {
1004
+ reject(new Error('Not connected to server'));
1005
+ return;
1006
+ }
1007
+ this.socket.emit('deleteTag', tagID, (response) => {
1008
+ if (response.ok) {
1009
+ this.safeLog('info', `Successfully deleted tag ${tagID}`);
1010
+ resolve(response);
1011
+ }
1012
+ else {
1013
+ reject(new Error(response.msg || 'Failed to delete tag'));
1014
+ }
1015
+ });
1016
+ });
1017
+ }
1018
+ // ─── Maintenance operations ─────────────────────────────────────────────────
1019
+ /**
1020
+ * Get the cached maintenance window list
1021
+ */
1022
+ getMaintenanceList() {
1023
+ return Object.values(this.maintenanceListCache);
1024
+ }
1025
+ /**
1026
+ * Create a new maintenance window
1027
+ *
1028
+ * @param maintenanceData - Maintenance window configuration
1029
+ * @returns Promise resolving to the API response with the maintenance ID
1030
+ */
1031
+ createMaintenance(maintenanceData) {
1032
+ return new Promise((resolve, reject) => {
1033
+ if (!this.socket || !this.socket.connected) {
1034
+ reject(new Error('Not connected to server'));
1035
+ return;
1036
+ }
1037
+ this.socket.emit('addMaintenance', maintenanceData, (response) => {
1038
+ if (response.ok) {
1039
+ this.safeLog('info', `Successfully created maintenance window (ID: ${response.maintenanceID})`);
1040
+ resolve(response);
1041
+ }
1042
+ else {
1043
+ reject(new Error(response.msg || 'Failed to create maintenance window'));
1044
+ }
1045
+ });
1046
+ });
1047
+ }
1048
+ // ─── Status page operations ─────────────────────────────────────────────────
1049
+ /**
1050
+ * Get the cached status page list
1051
+ */
1052
+ getStatusPageList() {
1053
+ return Object.values(this.statusPageListCache);
1054
+ }
1055
+ /**
1056
+ * Get full details of a single status page, including publicGroupList with monitors
1057
+ * and any active incidents. Uses the public HTTP API (`/api/status-page/{slug}`),
1058
+ * which returns the same data the status page UI renders — richer than the
1059
+ * socket `getStatusPage` event, which only returns config.
1060
+ *
1061
+ * @param slug - The status page slug
1062
+ * @returns Promise resolving to the status page config, groups, and incidents
1063
+ */
1064
+ async getStatusPage(slug) {
1065
+ try {
1066
+ const baseUrl = this.url.replace(/\/$/, '');
1067
+ const res = await fetch(`${baseUrl}/api/status-page/${encodeURIComponent(slug)}`);
1068
+ if (res.status === 404) {
1069
+ return { ok: false, msg: `Status page ${slug} not found` };
1070
+ }
1071
+ if (!res.ok) {
1072
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
1073
+ }
1074
+ const data = await res.json();
1075
+ return {
1076
+ ok: true,
1077
+ config: data.config,
1078
+ publicGroupList: data.publicGroupList,
1079
+ incidents: data.incidents ?? [],
1080
+ };
1081
+ }
1082
+ catch (error) {
1083
+ const msg = error instanceof Error ? error.message : 'Unknown error';
1084
+ throw new Error(`Failed to get status page ${slug}: ${msg}`);
1085
+ }
1086
+ }
1087
+ /**
1088
+ * Create a new (empty) status page with the given title and slug
1089
+ *
1090
+ * Note: This creates a blank status page. Use updateStatusPage afterwards to
1091
+ * set description, theme, groups, monitors, etc.
1092
+ *
1093
+ * @param title - Display title of the status page
1094
+ * @param slug - URL slug (lowercase letters, digits, and dashes only)
1095
+ * @returns Promise resolving to the API response
1096
+ */
1097
+ createStatusPage(title, slug) {
1098
+ return new Promise((resolve, reject) => {
1099
+ if (!this.socket || !this.socket.connected) {
1100
+ reject(new Error('Not connected to server'));
1101
+ return;
1102
+ }
1103
+ this.socket.emit('addStatusPage', title, slug, (response) => {
1104
+ if (response.ok) {
1105
+ this.safeLog('info', `Successfully created status page ${slug}`);
1106
+ resolve(response);
1107
+ }
1108
+ else {
1109
+ reject(new Error(response.msg || `Failed to create status page ${slug}`));
1110
+ }
1111
+ });
1112
+ });
1113
+ }
1114
+ /**
1115
+ * Update an existing status page's config and group/monitor list
1116
+ *
1117
+ * @param slug - The status page slug (immutable identifier)
1118
+ * @param config - Status page configuration (title, description, theme, published, etc.)
1119
+ * @param publicGroupList - Ordered groups, each with a name, weight, and monitorList `[{id}]`
1120
+ * @param imgDataUrl - Optional icon as data URL (pass empty string to keep existing)
1121
+ * @returns Promise resolving to the API response
1122
+ */
1123
+ updateStatusPage(slug, config, publicGroupList = [], imgDataUrl = '') {
1124
+ return new Promise((resolve, reject) => {
1125
+ if (!this.socket || !this.socket.connected) {
1126
+ reject(new Error('Not connected to server'));
1127
+ return;
1128
+ }
1129
+ this.socket.emit('saveStatusPage', slug, config, imgDataUrl, publicGroupList, (response) => {
1130
+ if (response.ok) {
1131
+ this.safeLog('info', `Successfully updated status page ${slug}`);
1132
+ resolve(response);
1133
+ }
1134
+ else {
1135
+ reject(new Error(response.msg || `Failed to update status page ${slug}`));
1136
+ }
1137
+ });
1138
+ });
1139
+ }
1140
+ /**
1141
+ * Delete a status page
1142
+ *
1143
+ * @param slug - The status page slug
1144
+ * @returns Promise resolving to the API response
1145
+ */
1146
+ deleteStatusPage(slug) {
1147
+ return new Promise((resolve, reject) => {
1148
+ if (!this.socket || !this.socket.connected) {
1149
+ reject(new Error('Not connected to server'));
1150
+ return;
1151
+ }
1152
+ this.socket.emit('deleteStatusPage', slug, (response) => {
1153
+ if (response.ok) {
1154
+ this.safeLog('info', `Successfully deleted status page ${slug}`);
1155
+ resolve(response);
1156
+ }
1157
+ else {
1158
+ reject(new Error(response.msg || `Failed to delete status page ${slug}`));
1159
+ }
1160
+ });
1161
+ });
1162
+ }
1163
+ // ─── Socket accessor ─────────────────────────────────────────────────────────
531
1164
  /**
532
1165
  * Get the socket instance (for advanced usage)
533
1166
  */