349 lines
13 KiB
TypeScript
349 lines
13 KiB
TypeScript
/**
|
|
* Redmine Settings View
|
|
*
|
|
* Configure the Redmine connection for this feature instance:
|
|
* - Base URL, Project ID, API Key, Root Tracker name
|
|
* - "Verbindung testen" -- calls whoAmI + getProject and reports the result
|
|
* - "Sync starten" -- pulls all (or only changed) tickets into the local mirror
|
|
*
|
|
* The user tests the feature directly here in Porta -- no pytest sandbox.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useState } from 'react';
|
|
|
|
import { useApiRequest } from '../../../hooks/useApi';
|
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
import {
|
|
RedmineConfigDto,
|
|
RedmineConnectionTestResult,
|
|
RedmineSyncResult,
|
|
RedmineSyncStatus,
|
|
deleteRedmineConfigApi,
|
|
getRedmineConfigApi,
|
|
getRedmineSyncStatusApi,
|
|
runRedmineSyncApi,
|
|
testRedmineConnectionApi,
|
|
updateRedmineConfigApi,
|
|
} from '../../../api/redmineApi';
|
|
|
|
import styles from './RedmineViews.module.css';
|
|
|
|
const _formatTs = (ts?: number | null): string => {
|
|
if (!ts) return '-';
|
|
try {
|
|
return new Date(ts * 1000).toLocaleString();
|
|
} catch {
|
|
return String(ts);
|
|
}
|
|
};
|
|
|
|
const _formatDuration = (ms?: number | null): string => {
|
|
if (ms == null) return '-';
|
|
if (ms < 1000) return `${ms} ms`;
|
|
const s = ms / 1000;
|
|
if (s < 60) return `${s.toFixed(1)} s`;
|
|
const m = s / 60;
|
|
return `${m.toFixed(1)} min`;
|
|
};
|
|
|
|
export const RedmineSettingsView: React.FC = () => {
|
|
const { t } = useLanguage();
|
|
const { request } = useApiRequest();
|
|
const instanceId = useInstanceId();
|
|
|
|
const [config, setConfig] = useState<RedmineConfigDto | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
const [syncing, setSyncing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [success, setSuccess] = useState<string | null>(null);
|
|
const [testResult, setTestResult] = useState<RedmineConnectionTestResult | null>(null);
|
|
const [syncResult, setSyncResult] = useState<RedmineSyncResult | null>(null);
|
|
const [syncStatus, setSyncStatus] = useState<RedmineSyncStatus | null>(null);
|
|
|
|
const [baseUrl, setBaseUrl] = useState('');
|
|
const [projectId, setProjectId] = useState('');
|
|
const [rootTrackerName, setRootTrackerName] = useState('Userstory');
|
|
const [apiKey, setApiKey] = useState('');
|
|
|
|
const _hydrate = useCallback((c: RedmineConfigDto) => {
|
|
setConfig(c);
|
|
setBaseUrl(c.baseUrl || '');
|
|
setProjectId(c.projectId || '');
|
|
setRootTrackerName(c.rootTrackerName || 'Userstory');
|
|
}, []);
|
|
|
|
const _loadStatus = useCallback(async () => {
|
|
if (!instanceId) return;
|
|
try {
|
|
const status = await getRedmineSyncStatusApi(request, instanceId);
|
|
setSyncStatus(status);
|
|
} catch {
|
|
// status is optional; don't block the page on failure
|
|
}
|
|
}, [request, instanceId]);
|
|
|
|
useEffect(() => {
|
|
if (!instanceId) return;
|
|
let cancelled = false;
|
|
(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const cfg = await getRedmineConfigApi(request, instanceId);
|
|
if (!cancelled) _hydrate(cfg);
|
|
await _loadStatus();
|
|
} catch (e: any) {
|
|
if (!cancelled) setError(e?.message || t('Fehler beim Laden'));
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => { cancelled = true; };
|
|
}, [request, instanceId, _hydrate, _loadStatus, t]);
|
|
|
|
const _save = useCallback(async () => {
|
|
if (!instanceId) return;
|
|
setSaving(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
const body: Record<string, any> = {
|
|
baseUrl: baseUrl.trim(),
|
|
projectId: projectId.trim(),
|
|
rootTrackerName: rootTrackerName.trim() || 'Userstory',
|
|
isActive: true,
|
|
};
|
|
if (apiKey.trim() !== '') body.apiKey = apiKey.trim();
|
|
const updated = await updateRedmineConfigApi(request, instanceId, body);
|
|
_hydrate(updated);
|
|
setApiKey('');
|
|
setSuccess(t('Einstellungen gespeichert.'));
|
|
setTimeout(() => setSuccess(null), 3000);
|
|
} catch (e: any) {
|
|
setError(e?.message || t('Fehler beim Speichern.'));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}, [request, instanceId, baseUrl, projectId, rootTrackerName, apiKey, _hydrate, t]);
|
|
|
|
const _test = useCallback(async () => {
|
|
if (!instanceId) return;
|
|
setTesting(true);
|
|
setError(null);
|
|
setTestResult(null);
|
|
try {
|
|
const result = await testRedmineConnectionApi(request, instanceId);
|
|
setTestResult(result);
|
|
} catch (e: any) {
|
|
setError(e?.message || t('Verbindungstest fehlgeschlagen.'));
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}, [request, instanceId, t]);
|
|
|
|
const _runSync = useCallback(async (force: boolean) => {
|
|
if (!instanceId) return;
|
|
setSyncing(true);
|
|
setError(null);
|
|
setSuccess(null);
|
|
setSyncResult(null);
|
|
try {
|
|
const result = await runRedmineSyncApi(request, instanceId, force);
|
|
setSyncResult(result);
|
|
await _loadStatus();
|
|
const cfg = await getRedmineConfigApi(request, instanceId);
|
|
_hydrate(cfg);
|
|
setSuccess(
|
|
t('Sync erfolgreich.') +
|
|
` ${result.ticketsUpserted} ${t('Tickets')}, ${result.relationsUpserted} ${t('Beziehungen')}, ${_formatDuration(result.durationMs)}.`,
|
|
);
|
|
} catch (e: any) {
|
|
setError(e?.message || t('Sync fehlgeschlagen.'));
|
|
} finally {
|
|
setSyncing(false);
|
|
}
|
|
}, [request, instanceId, _loadStatus, _hydrate, t]);
|
|
|
|
const _delete = useCallback(async () => {
|
|
if (!instanceId) return;
|
|
if (!window.confirm(t('Konfiguration wirklich loeschen? Der lokale Mirror bleibt erhalten.'))) return;
|
|
setError(null);
|
|
setSuccess(null);
|
|
try {
|
|
await deleteRedmineConfigApi(request, instanceId);
|
|
setBaseUrl('');
|
|
setProjectId('');
|
|
setRootTrackerName('Userstory');
|
|
setApiKey('');
|
|
setConfig(null);
|
|
setSuccess(t('Konfiguration geloescht.'));
|
|
} catch (e: any) {
|
|
setError(e?.message || t('Loeschen fehlgeschlagen.'));
|
|
}
|
|
}, [request, instanceId, t]);
|
|
|
|
if (loading) {
|
|
return <div className={styles.loading}>{t('Einstellungen werden geladen ...')}</div>;
|
|
}
|
|
|
|
const canTest = !!config?.hasApiKey && !!baseUrl && !!projectId;
|
|
const canSync = canTest;
|
|
|
|
return (
|
|
<div className={styles.page}>
|
|
<h2 className={styles.heading}>{t('Redmine -- Einstellungen')}</h2>
|
|
<p className={styles.subheading}>
|
|
{t('Verbindung dieser Feature-Instanz zu einem Redmine-Projekt. Speichern, testen, dann initialen Sync starten.')}
|
|
</p>
|
|
|
|
{error && <div className={styles.alertErr}>{error}</div>}
|
|
{success && <div className={styles.alertOk}>{success}</div>}
|
|
|
|
<div className={styles.section}>
|
|
<h3 className={styles.sectionTitle}>{t('Verbindung')}</h3>
|
|
|
|
<div className={styles.field}>
|
|
<label className={styles.label}>{t('Basis-URL')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.input}
|
|
value={baseUrl}
|
|
onChange={e => setBaseUrl(e.target.value)}
|
|
placeholder="https://redmine.example.com"
|
|
spellCheck={false}
|
|
/>
|
|
<div className={styles.hint}>{t('Ohne abschliessenden Slash, z.B. https://redmine.logobject.ch')}</div>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label className={styles.label}>{t('Projekt-ID oder -Slug')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.input}
|
|
value={projectId}
|
|
onChange={e => setProjectId(e.target.value)}
|
|
placeholder="logobject-mars"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label className={styles.label}>{t('Wurzel-Tracker (Name)')}</label>
|
|
<input
|
|
type="text"
|
|
className={styles.input}
|
|
value={rootTrackerName}
|
|
onChange={e => setRootTrackerName(e.target.value)}
|
|
placeholder="Userstory"
|
|
spellCheck={false}
|
|
/>
|
|
<div className={styles.hint}>
|
|
{t('Tracker, der die Wurzel der Ticket-Hierarchie bildet. Wird beim Sync gegen die Tracker-Liste aufgeloest.')}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label className={styles.label}>{t('API-Key')}</label>
|
|
<input
|
|
type="password"
|
|
className={styles.input}
|
|
value={apiKey}
|
|
onChange={e => setApiKey(e.target.value)}
|
|
placeholder={config?.hasApiKey ? t('(gesetzt -- leer lassen, um nicht zu aendern)') : t('Redmine API Access Key')}
|
|
spellCheck={false}
|
|
autoComplete="new-password"
|
|
/>
|
|
<div className={styles.hint}>
|
|
{t('Wird verschluesselt gespeichert. Status: ')}
|
|
{config?.hasApiKey ? <strong>{t('gesetzt')}</strong> : <em>{t('nicht gesetzt')}</em>}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.row}>
|
|
<button className={styles.btn} onClick={_save} disabled={saving}>
|
|
{saving ? t('Speichere ...') : t('Speichern')}
|
|
</button>
|
|
<button className={styles.btnSecondary} onClick={_test} disabled={!canTest || testing}>
|
|
{testing ? t('Teste ...') : t('Verbindung testen')}
|
|
</button>
|
|
{config?.id && (
|
|
<button className={styles.btnDanger} onClick={_delete}>
|
|
{t('Konfiguration loeschen')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{testResult && (
|
|
<div style={{ marginTop: '0.85rem' }}>
|
|
{testResult.ok ? (
|
|
<div className={styles.alertOk}>
|
|
<strong>{t('Verbindung OK')}.</strong>{' '}
|
|
{testResult.user?.name && <>{t('Angemeldet als')} <strong>{testResult.user.name}</strong>. </>}
|
|
{testResult.project?.name && <>{t('Projekt')}: <strong>{testResult.project.name}</strong>.</>}
|
|
</div>
|
|
) : (
|
|
<div className={styles.alertErr}>
|
|
<strong>{t('Verbindung fehlgeschlagen')}.</strong>{' '}
|
|
{testResult.message || testResult.reason || ''}
|
|
{testResult.status ? ` (HTTP ${testResult.status})` : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.section}>
|
|
<h3 className={styles.sectionTitle}>{t('Mirror-Sync')}</h3>
|
|
<p className={styles.hint} style={{ marginBottom: '0.85rem' }}>
|
|
{t('Tickets werden in die lokale Datenbank gespiegelt, damit Statistik und Browser auch bei 20\u2019000+ Tickets schnell sind. Nach Aenderungen wird das Mirror-Bild automatisch nachgezogen.')}
|
|
</p>
|
|
|
|
<div className={styles.kvGrid} style={{ marginBottom: '0.85rem' }}>
|
|
<div className={styles.kvLabel}>{t('Letzter Sync')}:</div>
|
|
<div className={styles.kvValue}>{_formatTs(config?.lastSyncAt)}</div>
|
|
<div className={styles.kvLabel}>{t('Letzter Full-Sync')}:</div>
|
|
<div className={styles.kvValue}>{_formatTs(config?.lastFullSyncAt)}</div>
|
|
<div className={styles.kvLabel}>{t('Letzte Sync-Dauer')}:</div>
|
|
<div className={styles.kvValue}>{_formatDuration(syncStatus?.lastSyncDurationMs)}</div>
|
|
<div className={styles.kvLabel}>{t('Tickets im Mirror')}:</div>
|
|
<div className={styles.kvValue}>{syncStatus?.mirroredTicketCount ?? '-'}</div>
|
|
<div className={styles.kvLabel}>{t('Beziehungen im Mirror')}:</div>
|
|
<div className={styles.kvValue}>{syncStatus?.mirroredRelationCount ?? '-'}</div>
|
|
{config?.lastSyncErrorMessage && (
|
|
<>
|
|
<div className={styles.kvLabel}>{t('Letzter Fehler')}:</div>
|
|
<div className={styles.kvValue} style={{ color: '#c53030' }}>{config.lastSyncErrorMessage}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className={styles.row}>
|
|
<button className={styles.btn} onClick={() => _runSync(false)} disabled={!canSync || syncing}>
|
|
{syncing ? t('Synchronisiere ...') : t('Sync starten (inkrementell)')}
|
|
</button>
|
|
<button className={styles.btnSecondary} onClick={() => _runSync(true)} disabled={!canSync || syncing}>
|
|
{t('Full-Sync (alle Tickets)')}
|
|
</button>
|
|
</div>
|
|
|
|
{syncResult && (
|
|
<div className={styles.alertInfo} style={{ marginTop: '0.85rem' }}>
|
|
<strong>{syncResult.full ? t('Full-Sync') : t('Inkrementeller Sync')}:</strong>{' '}
|
|
{syncResult.ticketsUpserted} {t('Tickets')}, {syncResult.relationsUpserted}{' '}
|
|
{t('Beziehungen')} in {_formatDuration(syncResult.durationMs)}.
|
|
</div>
|
|
)}
|
|
|
|
{!canSync && (
|
|
<div className={styles.hint} style={{ marginTop: '0.85rem' }}>
|
|
{t('Bitte zuerst Basis-URL, Projekt-ID und API-Key speichern.')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default RedmineSettingsView;
|