VersionChecker.tsx

  1import React, { useState, useEffect, useCallback } from "react";
  2import { api } from "../services/api";
  3import { VersionInfo, CommitInfo } from "../types";
  4
  5interface VersionCheckerProps {
  6  onUpdateAvailable?: (hasUpdate: boolean) => void;
  7}
  8
  9interface VersionModalProps {
 10  isOpen: boolean;
 11  onClose: () => void;
 12  versionInfo: VersionInfo | null;
 13  isLoading: boolean;
 14}
 15
 16function VersionModal({ isOpen, onClose, versionInfo, isLoading }: VersionModalProps) {
 17  const [commits, setCommits] = useState<CommitInfo[]>([]);
 18  const [loadingCommits, setLoadingCommits] = useState(false);
 19  const [upgrading, setUpgrading] = useState(false);
 20  const [restarting, setRestarting] = useState(false);
 21  const [upgradeMessage, setUpgradeMessage] = useState<string | null>(null);
 22  const [upgradeError, setUpgradeError] = useState<string | null>(null);
 23
 24  useEffect(() => {
 25    if (isOpen && versionInfo?.has_update && versionInfo.current_tag && versionInfo.latest_tag) {
 26      loadCommits(versionInfo.current_tag, versionInfo.latest_tag);
 27    }
 28  }, [isOpen, versionInfo]);
 29
 30  const loadCommits = async (currentTag: string, latestTag: string) => {
 31    setLoadingCommits(true);
 32    try {
 33      const result = await api.getChangelog(currentTag, latestTag);
 34      setCommits(result || []);
 35    } catch (err) {
 36      console.error("Failed to load changelog:", err);
 37      setCommits([]);
 38    } finally {
 39      setLoadingCommits(false);
 40    }
 41  };
 42
 43  const handleUpgrade = async () => {
 44    setUpgrading(true);
 45    setUpgradeError(null);
 46    setUpgradeMessage(null);
 47    try {
 48      const result = await api.upgrade();
 49      setUpgradeMessage(result.message);
 50    } catch (err) {
 51      const message = err instanceof Error ? err.message : "Unknown error";
 52      setUpgradeError(message);
 53    } finally {
 54      setUpgrading(false);
 55    }
 56  };
 57
 58  const handleExit = async () => {
 59    setRestarting(true);
 60    try {
 61      await api.exit();
 62      setTimeout(() => {
 63        window.location.reload();
 64      }, 2000);
 65    } catch {
 66      setTimeout(() => {
 67        window.location.reload();
 68      }, 2000);
 69    }
 70  };
 71
 72  if (!isOpen) return null;
 73
 74  const formatDateTime = (dateStr: string) => {
 75    const date = new Date(dateStr);
 76    return date.toLocaleString(undefined, {
 77      year: "numeric",
 78      month: "short",
 79      day: "numeric",
 80      hour: "2-digit",
 81      minute: "2-digit",
 82      timeZoneName: "short",
 83    });
 84  };
 85
 86  const getCommitUrl = (sha: string) => {
 87    return `https://github.com/boldsoftware/shelley/commit/${sha}`;
 88  };
 89
 90  return (
 91    <div className="version-modal-overlay" onClick={onClose}>
 92      <div className="version-modal" onClick={(e) => e.stopPropagation()}>
 93        <div className="version-modal-header">
 94          <h2>Version</h2>
 95          <button onClick={onClose} className="version-modal-close" aria-label="Close">
 96            <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
 97              <path
 98                strokeLinecap="round"
 99                strokeLinejoin="round"
100                strokeWidth={2}
101                d="M6 18L18 6M6 6l12 12"
102              />
103            </svg>
104          </button>
105        </div>
106
107        <div className="version-modal-content">
108          {isLoading ? (
109            <div className="version-loading">Checking for updates...</div>
110          ) : versionInfo ? (
111            <>
112              <div className="version-info-row">
113                <span className="version-label">Current:</span>
114                <span className="version-value">
115                  {versionInfo.current_tag || versionInfo.current_version || "dev"}
116                </span>
117                {versionInfo.current_commit_time && (
118                  <span className="version-date">
119                    ({formatDateTime(versionInfo.current_commit_time)})
120                  </span>
121                )}
122              </div>
123
124              {versionInfo.latest_tag && (
125                <div className="version-info-row">
126                  <span className="version-label">Latest:</span>
127                  <span className="version-value">{versionInfo.latest_tag}</span>
128                  {versionInfo.published_at && (
129                    <span className="version-date">
130                      ({formatDateTime(versionInfo.published_at)})
131                    </span>
132                  )}
133                </div>
134              )}
135
136              {versionInfo.error && (
137                <div className="version-error">
138                  <span>Error: {versionInfo.error}</span>
139                </div>
140              )}
141
142              {/* Changelog */}
143              {versionInfo.has_update && (
144                <div className="version-changelog">
145                  <h3>
146                    <a
147                      href={`https://github.com/boldsoftware/shelley/compare/${versionInfo.current_tag}...${versionInfo.latest_tag}`}
148                      target="_blank"
149                      rel="noopener noreferrer"
150                      className="changelog-link"
151                    >
152                      Changelog
153                    </a>
154                  </h3>
155                  {loadingCommits ? (
156                    <div className="version-loading">Loading...</div>
157                  ) : commits.length > 0 ? (
158                    <ul className="commit-list">
159                      {commits.map((commit) => (
160                        <li key={commit.sha} className="commit-item">
161                          <a
162                            href={getCommitUrl(commit.sha)}
163                            target="_blank"
164                            rel="noopener noreferrer"
165                            className="commit-sha"
166                          >
167                            {commit.sha}
168                          </a>
169                          <span className="commit-message">{commit.message}</span>
170                        </li>
171                      ))}
172                    </ul>
173                  ) : (
174                    <div className="version-no-commits">No commits found</div>
175                  )}
176                </div>
177              )}
178
179              {/* Upgrade/Restart buttons */}
180              {versionInfo.has_update && versionInfo.download_url && (
181                <div className="version-actions">
182                  {upgradeMessage && (
183                    <div className="version-success">
184                      Upgraded {versionInfo.executable_path || "shelley"}
185                    </div>
186                  )}
187                  {upgradeError && <div className="version-error">{upgradeError}</div>}
188
189                  {!upgradeMessage ? (
190                    <button
191                      onClick={handleUpgrade}
192                      disabled={upgrading}
193                      className="version-btn version-btn-primary"
194                    >
195                      {upgrading
196                        ? "Upgrading..."
197                        : `Upgrade ${versionInfo.executable_path || "shelley"} in place`}
198                    </button>
199                  ) : (
200                    <button
201                      onClick={handleExit}
202                      disabled={restarting}
203                      className="version-btn version-btn-primary"
204                    >
205                      {restarting
206                        ? versionInfo.running_under_systemd
207                          ? "Restarting..."
208                          : "Killing..."
209                        : versionInfo.running_under_systemd
210                          ? "Restart"
211                          : "Kill Shelley Server"}
212                    </button>
213                  )}
214                </div>
215              )}
216            </>
217          ) : (
218            <div className="version-loading">Loading...</div>
219          )}
220        </div>
221      </div>
222    </div>
223  );
224}
225
226export function useVersionChecker({ onUpdateAvailable }: VersionCheckerProps = {}) {
227  const [versionInfo, setVersionInfo] = useState<VersionInfo | null>(null);
228  const [showModal, setShowModal] = useState(false);
229  const [isLoading, setIsLoading] = useState(false);
230  const [shouldNotify, setShouldNotify] = useState(false);
231
232  const checkVersion = useCallback(async () => {
233    setIsLoading(true);
234    try {
235      // Always force refresh when checking
236      const info = await api.checkVersion(true);
237      setVersionInfo(info);
238      setShouldNotify(info.should_notify);
239      onUpdateAvailable?.(info.should_notify);
240    } catch (err) {
241      console.error("Failed to check version:", err);
242    } finally {
243      setIsLoading(false);
244    }
245  }, [onUpdateAvailable]);
246
247  // Check version on mount (uses cache)
248  useEffect(() => {
249    const checkInitial = async () => {
250      try {
251        const info = await api.checkVersion(false);
252        setVersionInfo(info);
253        setShouldNotify(info.should_notify);
254        onUpdateAvailable?.(info.should_notify);
255      } catch (err) {
256        console.error("Failed to check version:", err);
257      }
258    };
259    checkInitial();
260  }, [onUpdateAvailable]);
261
262  const openModal = useCallback(() => {
263    setShowModal(true);
264    // Always check for new version when opening modal
265    checkVersion();
266  }, [checkVersion]);
267
268  const closeModal = useCallback(() => {
269    setShowModal(false);
270  }, []);
271
272  const VersionModalComponent = (
273    <VersionModal
274      isOpen={showModal}
275      onClose={closeModal}
276      versionInfo={versionInfo}
277      isLoading={isLoading}
278    />
279  );
280
281  return {
282    hasUpdate: shouldNotify, // For red dot indicator (5+ days apart)
283    versionInfo,
284    openModal,
285    closeModal,
286    isLoading,
287    VersionModal: VersionModalComponent,
288  };
289}
290
291export default useVersionChecker;