8359500476
P3 — Skill-Configuration
- aria-brain/skills.py: SKILL_CONFIGS_FILE (/shared/config/skill_configs.json)
als zentrale Werte-Persistenz. _normalize_config_schema validiert die
Schema-Felder (name/type/label/secret/description/default), CFG_<UPPER_NAME>
ENV beim run_skill. create_skill + update_skill akzeptieren config_schema.
- agent.py: skill_set_config Brain-Tool fuer ARIA. skill_create/update um
config_schema-Property erweitert.
- main.py: GET/POST /skills/{name}/config — secret-Werte in Antwort gemaskt.
P4 — Versionierung mit Rollback
- aria-brain/skills.py: archive_current_version archiviert nach
versions/v_<ts>/ (ohne venv/logs). update_skill ruft das automatisch auf
bevor strukturelle Aenderungen passieren. list_skill_versions,
rollback_skill (mit Safety-Snapshot + automatischem venv-Rebuild),
delete_skill_version.
- agent.py: skill_list_versions, skill_rollback Brain-Tools.
- main.py: GET /skills/{name}/versions, POST /skills/{name}/rollback,
DELETE /skills/{name}/versions/{version_id}.
UI
- diagnostic/index.html: Skill-Detail um Config-Form (typ-spezifisch,
Secrets als password-Input mit ***SET***-Hinweis) und Versions-Liste
mit Rollback-/Delete-Button.
- android SkillBrowser: SkillDetailModal laedt config_schema + versions
on-mount. Config-Form (TextInput + Switch fuer boolean), Versionen mit
Rollback-Confirm. brainApi um SkillConfigField/SkillVersion +
getSkillConfig/setSkillConfig/listSkillVersions/rollbackSkill/
deleteSkillVersion erweitert.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
658 lines
24 KiB
TypeScript
658 lines
24 KiB
TypeScript
/**
|
||
* Skill-Browser — Liste aller Skills mit Toggle, Tap-zum-Details, Run,
|
||
* Logs und Loeschen.
|
||
*
|
||
* Eingesetzt von SettingsScreen → Sektion "Skills".
|
||
*
|
||
* Brain-API ueber brainApi (RVS-Brain-Proxy). Code-Edits laufen NICHT
|
||
* ueber diese UI — Skill-Code-Aenderungen sind ARIAs Domaene
|
||
* (skill_update Brain-Tool). Hier nur Manifest-Felder + Run + Cleanup.
|
||
*/
|
||
|
||
import React, { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
Alert,
|
||
FlatList,
|
||
Modal,
|
||
ScrollView,
|
||
StyleSheet,
|
||
Switch,
|
||
Text,
|
||
TextInput,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
|
||
import brainApi, { Skill, SkillConfigField, SkillVersion } from '../services/brainApi';
|
||
|
||
const COL_ACTIVE = '#34C759';
|
||
const COL_INACTIVE = '#555570';
|
||
const COL_ARIA = '#FFD60A';
|
||
const COL_STEFAN = '#0096FF';
|
||
|
||
function relTime(iso: string | null | undefined): string {
|
||
if (!iso) return '—';
|
||
const t = new Date(iso).getTime();
|
||
if (!t) return '—';
|
||
const diffSec = Math.floor((Date.now() - t) / 1000);
|
||
if (diffSec < 60) return `vor ${diffSec}s`;
|
||
if (diffSec < 3600) return `vor ${Math.floor(diffSec / 60)}min`;
|
||
if (diffSec < 86400) return `vor ${Math.floor(diffSec / 3600)}h`;
|
||
return `vor ${Math.floor(diffSec / 86400)}d`;
|
||
}
|
||
|
||
export const SkillBrowser: React.FC = () => {
|
||
const [items, setItems] = useState<Skill[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||
const [detail, setDetail] = useState<Skill | null>(null);
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true); setErr(null);
|
||
brainApi.listSkills()
|
||
.then(s => {
|
||
s.sort((a, b) => {
|
||
if (a.active !== b.active) return a.active ? -1 : 1;
|
||
return (a.name || '').localeCompare(b.name || '');
|
||
});
|
||
setItems(s);
|
||
})
|
||
.catch(e => setErr(String(e?.message || e)))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const visible = items.filter(s => {
|
||
if (filter === 'active') return s.active;
|
||
if (filter === 'inactive') return !s.active;
|
||
return true;
|
||
});
|
||
|
||
const toggleActive = (s: Skill) => {
|
||
brainApi.updateSkill(s.name, { active: !s.active })
|
||
.then(() => load())
|
||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||
};
|
||
|
||
const renderItem = ({ item }: { item: Skill }) => {
|
||
const isAria = (item.author || '').toLowerCase() === 'aria';
|
||
const authorColor = isAria ? COL_ARIA : COL_STEFAN;
|
||
const authorLabel = isAria ? '🤖 von ARIA' : '👤 von Stefan';
|
||
return (
|
||
<TouchableOpacity style={s.row} onPress={() => setDetail(item)}>
|
||
<View style={{flex: 1, marginRight: 8}}>
|
||
<View style={{flexDirection: 'row', alignItems: 'center', gap: 6, marginBottom: 4}}>
|
||
<Text style={{color: authorColor, fontSize: 10, fontWeight: '700'}}>{authorLabel}</Text>
|
||
<Text style={{color: '#E0E0F0', fontWeight: '600', flex: 1}} numberOfLines={1}>{item.name}</Text>
|
||
</View>
|
||
<Text style={{color: '#8888AA', fontSize: 12}} numberOfLines={2}>{item.description}</Text>
|
||
{item.setup_error ? (
|
||
<Text style={{color: '#FF6B6B', fontSize: 11, marginTop: 4}} numberOfLines={2}>
|
||
⚠ Setup-Fehler: {item.setup_error}
|
||
</Text>
|
||
) : null}
|
||
<Text style={{color: '#444460', fontSize: 10, marginTop: 4}}>
|
||
{item.execution} · {item.use_count || 0}× ausgefuehrt · zuletzt: {relTime(item.last_used)}
|
||
</Text>
|
||
</View>
|
||
<Switch
|
||
value={item.active}
|
||
onValueChange={() => toggleActive(item)}
|
||
trackColor={{ false: '#1E1E2E', true: COL_ACTIVE }}
|
||
thumbColor="#E0E0F0"
|
||
/>
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<View style={{flex: 1}}>
|
||
<View style={s.toolbar}>
|
||
{(['all', 'active', 'inactive'] as const).map(f => (
|
||
<TouchableOpacity
|
||
key={f}
|
||
style={[s.chip, filter === f && s.chipActive]}
|
||
onPress={() => setFilter(f)}
|
||
>
|
||
<Text style={{color: filter === f ? '#0D0D1A' : '#8888AA', fontSize: 12, fontWeight: '600'}}>
|
||
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktive' : 'Inaktive'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
))}
|
||
<View style={{flex: 1}} />
|
||
<TouchableOpacity onPress={load} style={s.iconBtn}>
|
||
<Text style={{fontSize: 16}}>{'↻'}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{err ? <Text style={s.err}>{err}</Text> : null}
|
||
|
||
{loading && items.length === 0 ? (
|
||
<ActivityIndicator color="#0096FF" style={{marginTop: 20}} />
|
||
) : (
|
||
<FlatList
|
||
data={visible}
|
||
keyExtractor={s => s.name}
|
||
renderItem={renderItem}
|
||
nestedScrollEnabled={true}
|
||
ListEmptyComponent={
|
||
<Text style={{color: '#555570', textAlign: 'center', padding: 20, fontStyle: 'italic'}}>
|
||
{items.length === 0
|
||
? '(noch keine Skills — frag ARIA: "bau mir einen Skill der ...")'
|
||
: '(keine Treffer für diesen Filter)'}
|
||
</Text>
|
||
}
|
||
contentContainerStyle={{paddingBottom: 20}}
|
||
/>
|
||
)}
|
||
|
||
{detail ? (
|
||
<SkillDetailModal
|
||
skill={detail}
|
||
onClose={() => setDetail(null)}
|
||
onReload={() => { load(); brainApi.getSkill(detail.name).then(setDetail).catch(() => {}); }}
|
||
/>
|
||
) : null}
|
||
</View>
|
||
);
|
||
};
|
||
|
||
// ── Detail-Modal mit Run + Logs + Delete ─────────────────────────────
|
||
|
||
interface DetailProps {
|
||
skill: Skill;
|
||
onClose: () => void;
|
||
onReload: () => void;
|
||
}
|
||
|
||
const SkillDetailModal: React.FC<DetailProps> = ({ skill, onClose, onReload }) => {
|
||
const [argValues, setArgValues] = useState<Record<string, string>>({});
|
||
const [running, setRunning] = useState(false);
|
||
const [runResult, setRunResult] = useState<{
|
||
ok: boolean; exit_code: number; stdout: string; stderr: string; duration_sec: number;
|
||
} | null>(null);
|
||
const [logs, setLogs] = useState<any[] | null>(null);
|
||
const [loadingLogs, setLoadingLogs] = useState(false);
|
||
|
||
// P3: Skill-Config (statische Werte je Skill, z.B. API-Keys)
|
||
const [cfgSchema, setCfgSchema] = useState<SkillConfigField[]>([]);
|
||
const [cfgValues, setCfgValues] = useState<Record<string, any>>({});
|
||
const [cfgDraft, setCfgDraft] = useState<Record<string, string>>({});
|
||
const [cfgSaving, setCfgSaving] = useState(false);
|
||
|
||
// P4: Versionen + Rollback
|
||
const [versions, setVersions] = useState<SkillVersion[]>([]);
|
||
const [versionsLoading, setVersionsLoading] = useState(false);
|
||
|
||
const args = Array.isArray(skill.args) ? skill.args : [];
|
||
|
||
// Config + Versionen beim Mount laden
|
||
useEffect(() => {
|
||
brainApi.getSkillConfig(skill.name)
|
||
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); })
|
||
.catch(() => {});
|
||
setVersionsLoading(true);
|
||
brainApi.listSkillVersions(skill.name)
|
||
.then(setVersions)
|
||
.catch(() => setVersions([]))
|
||
.finally(() => setVersionsLoading(false));
|
||
}, [skill.name]);
|
||
|
||
const setArg = (name: string, value: string) =>
|
||
setArgValues(prev => ({ ...prev, [name]: value }));
|
||
|
||
const run = () => {
|
||
setRunning(true); setRunResult(null);
|
||
const argsObj: Record<string, any> = {};
|
||
for (const a of args) {
|
||
if (a?.name && argValues[a.name] !== undefined && argValues[a.name] !== '') {
|
||
argsObj[a.name] = argValues[a.name];
|
||
}
|
||
}
|
||
brainApi.runSkill(skill.name, argsObj)
|
||
.then(r => setRunResult(r))
|
||
.catch(e => setRunResult({
|
||
ok: false, exit_code: -1, stdout: '', stderr: String(e?.message || e), duration_sec: 0,
|
||
}))
|
||
.finally(() => setRunning(false));
|
||
};
|
||
|
||
const loadLogs = () => {
|
||
setLoadingLogs(true);
|
||
brainApi.getSkillLogs(skill.name, 20)
|
||
.then(setLogs)
|
||
.catch(e => Alert.alert('Logs-Fehler', String(e?.message || e)))
|
||
.finally(() => setLoadingLogs(false));
|
||
};
|
||
|
||
const remove = () => {
|
||
Alert.alert(
|
||
'Skill loeschen?',
|
||
`"${skill.name}" wird komplett entfernt (venv, logs, manifest). Nicht rueckholbar.`,
|
||
[
|
||
{ text: 'Abbrechen', style: 'cancel' },
|
||
{
|
||
text: 'Loeschen',
|
||
style: 'destructive',
|
||
onPress: () => {
|
||
brainApi.deleteSkill(skill.name)
|
||
.then(() => { onReload(); onClose(); })
|
||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||
},
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
const saveConfig = () => {
|
||
// secret-Felder die als '***SET***' angezeigt sind und vom User NICHT
|
||
// angefasst wurden, bleiben auf dem alten Wert. cfgDraft enthaelt nur
|
||
// explizit getippte Werte; alles andere uebernehmen wir aus cfgValues.
|
||
const next: Record<string, any> = { ...cfgValues };
|
||
for (const f of cfgSchema) {
|
||
const draft = cfgDraft[f.name];
|
||
const isSecret = f.secret || f.type === 'password';
|
||
if (draft === undefined) continue;
|
||
if (isSecret && draft === '') continue; // leer = unveraendert
|
||
if (draft === '') { delete next[f.name]; continue; }
|
||
if (f.type === 'number') {
|
||
const n = Number(draft); next[f.name] = isNaN(n) ? draft : n;
|
||
} else if (f.type === 'boolean') {
|
||
next[f.name] = draft === 'true' || draft === '1';
|
||
} else {
|
||
next[f.name] = draft;
|
||
}
|
||
}
|
||
// Maskierte Werte (***SET***) niemals zurueckschreiben
|
||
for (const k of Object.keys(next)) if (next[k] === '***SET***') delete next[k];
|
||
setCfgSaving(true);
|
||
brainApi.setSkillConfig(skill.name, next)
|
||
.then(() => {
|
||
// frisch laden um neuen masked-State zu zeigen
|
||
return brainApi.getSkillConfig(skill.name);
|
||
})
|
||
.then(r => { setCfgSchema(r.schema || []); setCfgValues(r.values || {}); setCfgDraft({}); })
|
||
.catch(e => Alert.alert('Speichern fehlgeschlagen', String(e?.message || e)))
|
||
.finally(() => setCfgSaving(false));
|
||
};
|
||
|
||
const reloadVersions = () => {
|
||
setVersionsLoading(true);
|
||
brainApi.listSkillVersions(skill.name)
|
||
.then(setVersions)
|
||
.catch(() => {})
|
||
.finally(() => setVersionsLoading(false));
|
||
};
|
||
|
||
const doRollback = (versionId: string) => {
|
||
Alert.alert(
|
||
'Rollback?',
|
||
`Skill "${skill.name}" auf ${versionId} zuruecksetzen?\n\nDer aktuelle Stand wird vorher automatisch gesichert (safety-snapshot).`,
|
||
[
|
||
{ text: 'Abbrechen', style: 'cancel' },
|
||
{
|
||
text: 'Rollback', style: 'destructive',
|
||
onPress: () => {
|
||
brainApi.rollbackSkill(skill.name, versionId)
|
||
.then(r => {
|
||
Alert.alert('Rollback OK', `Safety-Snapshot: ${r.safety_snapshot}`);
|
||
reloadVersions(); onReload();
|
||
})
|
||
.catch(e => Alert.alert('Rollback fehlgeschlagen', String(e?.message || e)));
|
||
},
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
const removeVersion = (versionId: string) => {
|
||
Alert.alert(
|
||
'Version loeschen?',
|
||
`${versionId} dauerhaft entfernen?`,
|
||
[
|
||
{ text: 'Abbrechen', style: 'cancel' },
|
||
{
|
||
text: 'Loeschen', style: 'destructive',
|
||
onPress: () => {
|
||
brainApi.deleteSkillVersion(skill.name, versionId)
|
||
.then(reloadVersions)
|
||
.catch(e => Alert.alert('Fehler', String(e?.message || e)));
|
||
},
|
||
},
|
||
],
|
||
);
|
||
};
|
||
|
||
return (
|
||
<Modal visible animationType="slide" onRequestClose={onClose} transparent={false}>
|
||
<View style={s.modal}>
|
||
<View style={s.modalHeader}>
|
||
<Text style={s.modalTitle} numberOfLines={1}>{skill.name}</Text>
|
||
<TouchableOpacity onPress={onClose} hitSlop={{top:8,bottom:8,left:8,right:8}}>
|
||
<Text style={{color: '#8888AA', fontSize: 18}}>{'✕'}</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<ScrollView style={{flex: 1}} contentContainerStyle={{padding: 16}}>
|
||
<Text style={s.label}>Beschreibung</Text>
|
||
<Text style={{color: '#E0E0F0', marginBottom: 12}}>{skill.description}</Text>
|
||
|
||
<View style={s.metaBox}>
|
||
<Text style={s.meta}>execution: {skill.execution} · entry: {skill.entry}</Text>
|
||
<Text style={s.meta}>author: {skill.author || '?'} · version: {skill.version || '?'}</Text>
|
||
<Text style={s.meta}>{skill.use_count || 0}× ausgefuehrt · zuletzt: {relTime(skill.last_used)}</Text>
|
||
{skill.setup_error ? (
|
||
<Text style={[s.meta, {color: '#FF6B6B'}]}>setup_error: {skill.setup_error}</Text>
|
||
) : null}
|
||
{Array.isArray(skill.requires?.pip) && skill.requires!.pip!.length > 0 ? (
|
||
<Text style={s.meta}>pip: {skill.requires!.pip!.join(', ')}</Text>
|
||
) : null}
|
||
</View>
|
||
|
||
{/* Args-Inputs */}
|
||
{args.length > 0 ? (
|
||
<>
|
||
<Text style={[s.label, {marginTop: 18}]}>Argumente</Text>
|
||
{args.map((a: any) => (
|
||
<View key={a.name} style={{marginBottom: 10}}>
|
||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||
{a.name}{a.required ? ' *' : ''} {a.description ? `— ${a.description}` : ''}
|
||
</Text>
|
||
<TextInput
|
||
style={s.input}
|
||
value={argValues[a.name] || ''}
|
||
onChangeText={(v) => setArg(a.name, v)}
|
||
placeholder={a.type || 'string'}
|
||
placeholderTextColor="#444460"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
/>
|
||
</View>
|
||
))}
|
||
</>
|
||
) : null}
|
||
|
||
{/* Config-Schema-Form (P3) */}
|
||
{cfgSchema.length > 0 ? (
|
||
<>
|
||
<Text style={[s.label, {marginTop: 18}]}>⚙ Konfiguration</Text>
|
||
{cfgSchema.map((f) => {
|
||
const isSecret = f.secret || f.type === 'password';
|
||
const cur = cfgValues[f.name];
|
||
const isSet = isSecret && cur === '***SET***';
|
||
const placeholder = isSet ? '••• gesetzt — leer lassen = unverändert'
|
||
: (f.default !== undefined && f.default !== null ? `Default: ${String(f.default)}` : (f.type || 'string'));
|
||
const valStr = cfgDraft[f.name] !== undefined
|
||
? cfgDraft[f.name]
|
||
: (isSecret ? '' : (cur !== undefined && cur !== null && cur !== '***SET***' ? String(cur) : ''));
|
||
if (f.type === 'boolean') {
|
||
const bv = cfgDraft[f.name] !== undefined
|
||
? (cfgDraft[f.name] === 'true')
|
||
: (cur === true || cur === 'true');
|
||
return (
|
||
<View key={f.name} style={{marginBottom: 10, flexDirection: 'row', alignItems: 'center', gap: 10}}>
|
||
<Switch value={bv} onValueChange={(v) => setCfgDraft(p => ({...p, [f.name]: v ? 'true' : 'false'}))}
|
||
trackColor={{false: '#1E1E2E', true: '#0096FF'}} thumbColor="#fff" />
|
||
<View style={{flex: 1}}>
|
||
<Text style={{color: '#E0E0F0', fontSize: 13}}>{f.label || f.name}</Text>
|
||
{f.description ? <Text style={{color: '#555570', fontSize: 11}}>{f.description}</Text> : null}
|
||
</View>
|
||
</View>
|
||
);
|
||
}
|
||
return (
|
||
<View key={f.name} style={{marginBottom: 10}}>
|
||
<Text style={{color: '#8888AA', fontSize: 12, marginBottom: 4}}>
|
||
{f.label || f.name}{isSecret ? ' 🔒' : ''}
|
||
{f.description ? <Text style={{color: '#555570'}}> — {f.description}</Text> : null}
|
||
</Text>
|
||
<TextInput
|
||
style={s.input}
|
||
value={valStr}
|
||
onChangeText={(v) => setCfgDraft(p => ({...p, [f.name]: v}))}
|
||
placeholder={placeholder}
|
||
placeholderTextColor="#444460"
|
||
autoCapitalize="none"
|
||
autoCorrect={false}
|
||
secureTextEntry={isSecret}
|
||
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
|
||
/>
|
||
</View>
|
||
);
|
||
})}
|
||
<TouchableOpacity
|
||
style={[s.btn, {backgroundColor: '#1A1A2E', borderColor: COL_ACTIVE, marginTop: 4}]}
|
||
onPress={saveConfig}
|
||
disabled={cfgSaving}
|
||
>
|
||
<Text style={{color: COL_ACTIVE, textAlign: 'center', fontWeight: '700'}}>
|
||
{cfgSaving ? 'Speichere...' : '💾 Konfiguration speichern'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
</>
|
||
) : null}
|
||
|
||
{/* Versionen (P4) */}
|
||
{versions.length > 0 ? (
|
||
<>
|
||
<Text style={[s.label, {marginTop: 18}]}>📦 Versionen ({versions.length})</Text>
|
||
{versions.map(v => (
|
||
<View key={v.version_id} style={[s.metaBox, {marginTop: 6, flexDirection: 'row', alignItems: 'center', gap: 6}]}>
|
||
<View style={{flex: 1}}>
|
||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#E0E0F0'}]}>{v.version_id}</Text>
|
||
<Text style={s.meta}>{v.archived_at ? new Date(v.archived_at).toLocaleString('de-DE') : '—'}</Text>
|
||
{v.summary ? <Text style={[s.meta, {fontStyle: 'italic'}]} numberOfLines={2}>{v.summary}</Text> : null}
|
||
</View>
|
||
<TouchableOpacity onPress={() => doRollback(v.version_id)}
|
||
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: COL_ARIA, backgroundColor: '#1A1A2E'}]}>
|
||
<Text style={{color: COL_ARIA, fontSize: 12}}>↺</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity onPress={() => removeVersion(v.version_id)}
|
||
style={[s.btn, {paddingHorizontal: 10, paddingVertical: 6, borderColor: '#FF6B6B', backgroundColor: '#1A1A2E'}]}>
|
||
<Text style={{color: '#FF6B6B', fontSize: 12}}>🗑</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
))}
|
||
</>
|
||
) : versionsLoading ? (
|
||
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||
) : null}
|
||
|
||
<View style={{flexDirection: 'row', gap: 8, marginTop: 14}}>
|
||
<TouchableOpacity
|
||
style={[s.btn, {backgroundColor: skill.active ? '#0096FF' : '#1E1E2E', flex: 1}]}
|
||
onPress={run}
|
||
disabled={!skill.active || running}
|
||
>
|
||
<Text style={{color: skill.active ? '#fff' : '#555570', fontWeight: '700', textAlign: 'center'}}>
|
||
{running ? 'läuft...' : '▶ Ausführen'}
|
||
</Text>
|
||
</TouchableOpacity>
|
||
<TouchableOpacity
|
||
style={[s.btn, {backgroundColor: '#1A1A2E', flex: 1}]}
|
||
onPress={loadLogs}
|
||
>
|
||
<Text style={{color: '#8888AA', textAlign: 'center'}}>📜 Logs</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
{!skill.active ? (
|
||
<Text style={{color: '#FFD60A', fontSize: 12, marginTop: 6, fontStyle: 'italic'}}>
|
||
Skill ist deaktiviert — toggle in der Liste zum Aktivieren.
|
||
</Text>
|
||
) : null}
|
||
|
||
{/* Run-Result */}
|
||
{runResult ? (
|
||
<View style={[s.metaBox, {marginTop: 14, borderLeftWidth: 3, borderLeftColor: runResult.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||
<Text style={[s.meta, {color: runResult.ok ? COL_ACTIVE : '#FF6B6B', fontWeight: '700'}]}>
|
||
{runResult.ok ? '✓ OK' : `✗ FEHLER (exit ${runResult.exit_code})`} · {runResult.duration_sec}s
|
||
</Text>
|
||
{runResult.stdout ? (
|
||
<>
|
||
<Text style={[s.meta, {marginTop: 6, color: '#8888AA', fontWeight: '600'}]}>stdout:</Text>
|
||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]}>{runResult.stdout}</Text>
|
||
</>
|
||
) : null}
|
||
{runResult.stderr ? (
|
||
<>
|
||
<Text style={[s.meta, {marginTop: 6, color: '#FF6B6B', fontWeight: '600'}]}>stderr:</Text>
|
||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#FF9999'}]}>{runResult.stderr}</Text>
|
||
</>
|
||
) : null}
|
||
</View>
|
||
) : null}
|
||
|
||
{/* Logs */}
|
||
{loadingLogs ? (
|
||
<ActivityIndicator color="#0096FF" style={{marginTop: 14}} />
|
||
) : logs ? (
|
||
<View style={{marginTop: 14}}>
|
||
<Text style={[s.label, {marginTop: 0}]}>Letzte Runs (Top 20)</Text>
|
||
{logs.length === 0 ? (
|
||
<Text style={{color: '#555570', fontStyle: 'italic'}}>(keine Logs)</Text>
|
||
) : logs.map((log, idx) => (
|
||
<View key={idx} style={[s.metaBox, {marginTop: 6, borderLeftWidth: 2, borderLeftColor: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||
<Text style={[s.meta, {color: log.ok ? COL_ACTIVE : '#FF6B6B'}]}>
|
||
{log.ok ? '✓' : '✗'} {log.ts ? new Date(log.ts).toLocaleString('de-DE') : '?'} · {log.duration_sec || 0}s
|
||
</Text>
|
||
{log.stdout ? (
|
||
<Text style={[s.meta, {fontFamily: 'monospace', color: '#C0C0D0'}]} numberOfLines={3}>
|
||
{String(log.stdout).slice(0, 300)}
|
||
</Text>
|
||
) : null}
|
||
</View>
|
||
))}
|
||
</View>
|
||
) : null}
|
||
|
||
<View style={{height: 30}} />
|
||
</ScrollView>
|
||
|
||
<View style={s.modalFooter}>
|
||
<TouchableOpacity style={[s.btn, {backgroundColor: '#3A1F1F', borderColor: '#FF6B6B'}]} onPress={remove}>
|
||
<Text style={{color: '#FF6B6B', fontWeight: '700'}}>🗑 Loeschen</Text>
|
||
</TouchableOpacity>
|
||
<View style={{flex: 1}} />
|
||
<TouchableOpacity style={[s.btn, {backgroundColor: '#1A1A2E'}]} onPress={onClose}>
|
||
<Text style={{color: '#8888AA'}}>Schliessen</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
// ── Styles ───────────────────────────────────────────────────────────
|
||
|
||
const s = StyleSheet.create({
|
||
toolbar: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
gap: 6,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 8,
|
||
backgroundColor: '#0D0D1A',
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#1E1E2E',
|
||
},
|
||
chip: {
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 5,
|
||
borderRadius: 12,
|
||
backgroundColor: '#1A1A2E',
|
||
},
|
||
chipActive: {
|
||
backgroundColor: '#FFD60A',
|
||
},
|
||
iconBtn: {
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 5,
|
||
borderRadius: 6,
|
||
backgroundColor: '#1A1A2E',
|
||
},
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingVertical: 12,
|
||
paddingHorizontal: 14,
|
||
backgroundColor: '#0D0D1A',
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#1E1E2E',
|
||
},
|
||
err: {
|
||
color: '#FF6B6B',
|
||
padding: 12,
|
||
fontSize: 12,
|
||
},
|
||
modal: {
|
||
flex: 1,
|
||
backgroundColor: '#0D0D1A',
|
||
},
|
||
modalHeader: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
justifyContent: 'space-between',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 12,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: '#1E1E2E',
|
||
},
|
||
modalTitle: {
|
||
color: '#E0E0F0',
|
||
fontSize: 16,
|
||
fontWeight: '700',
|
||
flex: 1,
|
||
marginRight: 12,
|
||
},
|
||
modalFooter: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
padding: 12,
|
||
borderTopWidth: 1,
|
||
borderTopColor: '#1E1E2E',
|
||
gap: 8,
|
||
},
|
||
label: {
|
||
color: '#8888AA',
|
||
fontSize: 11,
|
||
fontWeight: '700',
|
||
textTransform: 'uppercase',
|
||
letterSpacing: 0.5,
|
||
marginTop: 8,
|
||
marginBottom: 4,
|
||
},
|
||
input: {
|
||
backgroundColor: '#1A1A2E',
|
||
borderWidth: 1,
|
||
borderColor: '#1E1E2E',
|
||
borderRadius: 6,
|
||
color: '#E0E0F0',
|
||
padding: 10,
|
||
fontSize: 14,
|
||
},
|
||
metaBox: {
|
||
backgroundColor: '#1A1A2E',
|
||
borderRadius: 6,
|
||
padding: 10,
|
||
marginTop: 6,
|
||
gap: 4,
|
||
},
|
||
meta: {
|
||
color: '#8888AA',
|
||
fontSize: 12,
|
||
},
|
||
btn: {
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 10,
|
||
borderRadius: 6,
|
||
borderWidth: 1,
|
||
borderColor: 'transparent',
|
||
},
|
||
});
|
||
|
||
export default SkillBrowser;
|