Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c915d5f2ad |
+39
-2
@@ -10,7 +10,7 @@ import {
|
|||||||
import { useLocalSearchParams, router, useNavigation } from "expo-router";
|
import { useLocalSearchParams, router, useNavigation } from "expo-router";
|
||||||
import * as Clipboard from "expo-clipboard";
|
import * as Clipboard from "expo-clipboard";
|
||||||
import * as SecureStore from "expo-secure-store";
|
import * as SecureStore from "expo-secure-store";
|
||||||
import { getIncidentById, updateIncident } from "@/lib/db";
|
import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db";
|
||||||
import { renderMarkdown } from "@/lib/markdown";
|
import { renderMarkdown } from "@/lib/markdown";
|
||||||
import { pushIncidentToGit } from "@/lib/git";
|
import { pushIncidentToGit } from "@/lib/git";
|
||||||
import type { GitProvider } from "@/lib/git";
|
import type { GitProvider } from "@/lib/git";
|
||||||
@@ -54,6 +54,25 @@ export default function IncidentDetailScreen() {
|
|||||||
}
|
}
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!incident) return;
|
||||||
|
Alert.alert(
|
||||||
|
"Delete incident?",
|
||||||
|
"This action cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: async () => {
|
||||||
|
await deleteIncident(incident.id);
|
||||||
|
router.replace("/");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleResolve() {
|
async function handleResolve() {
|
||||||
if (!incident) return;
|
if (!incident) return;
|
||||||
await updateIncident(incident.id, { status: "resolved" });
|
await updateIncident(incident.id, { status: "resolved" });
|
||||||
@@ -102,7 +121,8 @@ export default function IncidentDetailScreen() {
|
|||||||
repo = gitlabRepo;
|
repo = gitlabRepo;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token || !owner || !repo) {
|
const needsUrl = p === "gitea" || p === "gitlab";
|
||||||
|
if (!token || !owner || !repo || (needsUrl && !url)) {
|
||||||
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
|
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -291,6 +311,23 @@ export default function IncidentDetailScreen() {
|
|||||||
Edit
|
Edit
|
||||||
</Text>
|
</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleDelete}
|
||||||
|
style={{
|
||||||
|
backgroundColor: "#ef444410",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "#ef4444",
|
||||||
|
borderRadius: 10,
|
||||||
|
padding: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: "#ef4444", fontWeight: "600", fontSize: 15 }}>
|
||||||
|
Delete Incident
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|||||||
+20
@@ -10,6 +10,7 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
import {
|
import {
|
||||||
createIncident,
|
createIncident,
|
||||||
updateIncident,
|
updateIncident,
|
||||||
@@ -48,6 +49,14 @@ const LABEL_STYLE = {
|
|||||||
|
|
||||||
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
|
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
|
||||||
|
|
||||||
|
function incrementTemplate(template: string): string {
|
||||||
|
const match = template.match(/^([\s\S]*?)(\d+)$/);
|
||||||
|
if (!match) return template;
|
||||||
|
const [, prefix, numStr] = match;
|
||||||
|
const next = (parseInt(numStr, 10) + 1).toString().padStart(numStr.length, "0");
|
||||||
|
return prefix + next;
|
||||||
|
}
|
||||||
|
|
||||||
export default function NewIncidentScreen() {
|
export default function NewIncidentScreen() {
|
||||||
const { editId } = useLocalSearchParams<{ editId?: string }>();
|
const { editId } = useLocalSearchParams<{ editId?: string }>();
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
@@ -74,6 +83,13 @@ export default function NewIncidentScreen() {
|
|||||||
getDistinctServices().then(setKnownServices);
|
getDistinctServices().then(setKnownServices);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (editId) return;
|
||||||
|
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
|
||||||
|
if (tpl) setForm((f) => ({ ...f, title: tpl }));
|
||||||
|
});
|
||||||
|
}, [editId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
navigation.setOptions({ title: "Edit Incident" });
|
navigation.setOptions({ title: "Edit Incident" });
|
||||||
@@ -144,6 +160,10 @@ export default function NewIncidentScreen() {
|
|||||||
await updateIncident(editId, form);
|
await updateIncident(editId, form);
|
||||||
} else {
|
} else {
|
||||||
await createIncident(form);
|
await createIncident(form);
|
||||||
|
const tpl = await SecureStore.getItemAsync("pref_title_template");
|
||||||
|
if (tpl) {
|
||||||
|
await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
router.back();
|
router.back();
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
+218
-146
@@ -17,18 +17,18 @@ const KEYS = {
|
|||||||
HOME_VIEW: "pref_home_view",
|
HOME_VIEW: "pref_home_view",
|
||||||
AI_ENABLED: "pref_ai_enabled",
|
AI_ENABLED: "pref_ai_enabled",
|
||||||
AI_KEY: "pref_ai_key",
|
AI_KEY: "pref_ai_key",
|
||||||
|
AI_BASE_URL: "pref_ai_base_url",
|
||||||
|
AI_MODEL: "pref_ai_model",
|
||||||
|
TITLE_TEMPLATE: "pref_title_template",
|
||||||
MD_TEMPLATE: "md_template",
|
MD_TEMPLATE: "md_template",
|
||||||
GIT_PROVIDER: "git_provider",
|
GIT_PROVIDER: "git_provider",
|
||||||
// Gitea
|
|
||||||
GITEA_URL: "gitea_url",
|
GITEA_URL: "gitea_url",
|
||||||
GITEA_TOKEN: "gitea_token",
|
GITEA_TOKEN: "gitea_token",
|
||||||
GITEA_OWNER: "gitea_owner",
|
GITEA_OWNER: "gitea_owner",
|
||||||
GITEA_REPO: "gitea_repo",
|
GITEA_REPO: "gitea_repo",
|
||||||
// GitHub
|
|
||||||
GITHUB_TOKEN: "github_token",
|
GITHUB_TOKEN: "github_token",
|
||||||
GITHUB_OWNER: "github_owner",
|
GITHUB_OWNER: "github_owner",
|
||||||
GITHUB_REPO: "github_repo",
|
GITHUB_REPO: "github_repo",
|
||||||
// GitLab
|
|
||||||
GITLAB_URL: "gitlab_url",
|
GITLAB_URL: "gitlab_url",
|
||||||
GITLAB_TOKEN: "gitlab_token",
|
GITLAB_TOKEN: "gitlab_token",
|
||||||
GITLAB_OWNER: "gitlab_owner",
|
GITLAB_OWNER: "gitlab_owner",
|
||||||
@@ -70,26 +70,140 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
|
|||||||
gitlab: "GitLab",
|
gitlab: "GitLab",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Defined outside SettingsScreen — prevents remount on every rerender, which
|
||||||
|
// would cause TextInput to lose focus after each keystroke.
|
||||||
|
|
||||||
|
function ToggleRow({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
|
backgroundColor: Colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 14,
|
||||||
|
marginBottom: 10,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ true: Colors.primary, false: Colors.border }}
|
||||||
|
thumbColor="#fff"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SegmentRow({
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
options: { key: string; label: string }[];
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={{ marginBottom: 16 }}>
|
||||||
|
<Text style={LABEL}>{label}</Text>
|
||||||
|
<View style={{ flexDirection: "row", gap: 8 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<Pressable
|
||||||
|
key={opt.key}
|
||||||
|
onPress={() => onChange(opt.key)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor:
|
||||||
|
value === opt.key ? Colors.primary : Colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor:
|
||||||
|
value === opt.key ? Colors.primary : Colors.border,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
style={{
|
||||||
|
color: value === opt.key ? Colors.bg : Colors.text2,
|
||||||
|
fontWeight: "700",
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
secure,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
secure?: boolean;
|
||||||
|
url?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text style={LABEL}>{label}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={INPUT}
|
||||||
|
value={value}
|
||||||
|
onChangeText={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor={Colors.textDim}
|
||||||
|
secureTextEntry={secure}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
keyboardType={url ? "url" : "default"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function SettingsScreen() {
|
export default function SettingsScreen() {
|
||||||
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
|
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
|
||||||
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
|
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
|
||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiKey, setAiKey] = useState("");
|
const [aiKey, setAiKey] = useState("");
|
||||||
|
const [aiBaseUrl, setAiBaseUrl] = useState("");
|
||||||
|
const [aiModel, setAiModel] = useState("");
|
||||||
|
const [titleTemplate, setTitleTemplate] = useState("");
|
||||||
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
|
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
|
||||||
const [saved, setSaved] = useState(false);
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
// Git provider
|
|
||||||
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
|
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
|
||||||
// Gitea
|
|
||||||
const [giteaUrl, setGiteaUrl] = useState("");
|
const [giteaUrl, setGiteaUrl] = useState("");
|
||||||
const [giteaToken, setGiteaToken] = useState("");
|
const [giteaToken, setGiteaToken] = useState("");
|
||||||
const [giteaOwner, setGiteaOwner] = useState("");
|
const [giteaOwner, setGiteaOwner] = useState("");
|
||||||
const [giteaRepo, setGiteaRepo] = useState("");
|
const [giteaRepo, setGiteaRepo] = useState("");
|
||||||
// GitHub
|
|
||||||
const [githubToken, setGithubToken] = useState("");
|
const [githubToken, setGithubToken] = useState("");
|
||||||
const [githubOwner, setGithubOwner] = useState("");
|
const [githubOwner, setGithubOwner] = useState("");
|
||||||
const [githubRepo, setGithubRepo] = useState("");
|
const [githubRepo, setGithubRepo] = useState("");
|
||||||
// GitLab
|
|
||||||
const [gitlabUrl, setGitlabUrl] = useState("");
|
const [gitlabUrl, setGitlabUrl] = useState("");
|
||||||
const [gitlabToken, setGitlabToken] = useState("");
|
const [gitlabToken, setGitlabToken] = useState("");
|
||||||
const [gitlabOwner, setGitlabOwner] = useState("");
|
const [gitlabOwner, setGitlabOwner] = useState("");
|
||||||
@@ -97,30 +211,40 @@ export default function SettingsScreen() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const [im, hv, ai, key, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
|
const [
|
||||||
await Promise.all([
|
im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl,
|
||||||
SecureStore.getItemAsync(KEYS.INPUT_MODE),
|
gp, gu, gt, go, gr,
|
||||||
SecureStore.getItemAsync(KEYS.HOME_VIEW),
|
ghu, ghown, ghrepo,
|
||||||
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
glu, glt, glo, glr,
|
||||||
SecureStore.getItemAsync(KEYS.AI_KEY),
|
] = await Promise.all([
|
||||||
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
SecureStore.getItemAsync(KEYS.INPUT_MODE),
|
||||||
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
|
SecureStore.getItemAsync(KEYS.HOME_VIEW),
|
||||||
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
||||||
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
SecureStore.getItemAsync(KEYS.AI_KEY),
|
||||||
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
|
||||||
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
SecureStore.getItemAsync(KEYS.AI_MODEL),
|
||||||
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
|
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
|
||||||
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
|
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
||||||
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
|
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
|
||||||
SecureStore.getItemAsync(KEYS.GITLAB_URL),
|
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
||||||
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
|
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
||||||
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
|
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
||||||
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
|
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
||||||
]);
|
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITLAB_URL),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
|
||||||
|
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
|
||||||
|
]);
|
||||||
if (im) setInputMode(im as "voice" | "form");
|
if (im) setInputMode(im as "voice" | "form");
|
||||||
if (hv) setHomeView(hv as "dashboard" | "capture");
|
if (hv) setHomeView(hv as "dashboard" | "capture");
|
||||||
if (ai) setAiEnabled(ai === "true");
|
if (ai) setAiEnabled(ai === "true");
|
||||||
if (key) setAiKey(key);
|
if (key) setAiKey(key);
|
||||||
|
if (aiUrl) setAiBaseUrl(aiUrl);
|
||||||
|
if (aiMdl) setAiModel(aiMdl);
|
||||||
|
if (titleTpl) setTitleTemplate(titleTpl);
|
||||||
if (tpl) setMdTemplate(tpl);
|
if (tpl) setMdTemplate(tpl);
|
||||||
if (gp) setGitProvider(gp as GitProvider);
|
if (gp) setGitProvider(gp as GitProvider);
|
||||||
if (gu) setGiteaUrl(gu);
|
if (gu) setGiteaUrl(gu);
|
||||||
@@ -143,6 +267,9 @@ export default function SettingsScreen() {
|
|||||||
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
|
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
|
||||||
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
|
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
|
||||||
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
|
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
|
||||||
|
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
|
||||||
|
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
|
||||||
|
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
|
||||||
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
|
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
|
||||||
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
|
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
|
||||||
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
|
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
|
||||||
@@ -161,117 +288,6 @@ export default function SettingsScreen() {
|
|||||||
setTimeout(() => setSaved(false), 2000);
|
setTimeout(() => setSaved(false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ToggleRow({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onValueChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: boolean;
|
|
||||||
onValueChange: (v: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
style={{
|
|
||||||
flexDirection: "row",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
alignItems: "center",
|
|
||||||
backgroundColor: Colors.surface,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 14,
|
|
||||||
marginBottom: 10,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
|
|
||||||
<Switch
|
|
||||||
value={value}
|
|
||||||
onValueChange={onValueChange}
|
|
||||||
trackColor={{ true: Colors.primary, false: Colors.border }}
|
|
||||||
thumbColor="#fff"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SegmentRow({
|
|
||||||
label,
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
options: { key: string; label: string }[];
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<View style={{ marginBottom: 16 }}>
|
|
||||||
<Text style={LABEL}>{label}</Text>
|
|
||||||
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<Pressable
|
|
||||||
key={opt.key}
|
|
||||||
onPress={() => onChange(opt.key)}
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor:
|
|
||||||
value === opt.key ? Colors.primary : Colors.surface,
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
alignItems: "center",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor:
|
|
||||||
value === opt.key ? Colors.primary : Colors.border,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={{
|
|
||||||
color: value === opt.key ? Colors.bg : Colors.text2,
|
|
||||||
fontWeight: "700",
|
|
||||||
fontSize: 13,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder,
|
|
||||||
secure,
|
|
||||||
url,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
secure?: boolean;
|
|
||||||
url?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Text style={LABEL}>{label}</Text>
|
|
||||||
<TextInput
|
|
||||||
style={INPUT}
|
|
||||||
value={value}
|
|
||||||
onChangeText={onChange}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={Colors.textDim}
|
|
||||||
secureTextEntry={secure}
|
|
||||||
autoCapitalize="none"
|
|
||||||
keyboardType={url ? "url" : "default"}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={{ flex: 1, backgroundColor: Colors.bg }}
|
style={{ flex: 1, backgroundColor: Colors.bg }}
|
||||||
@@ -300,20 +316,76 @@ export default function SettingsScreen() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Text style={SECTION_TITLE}>AI (optional)</Text>
|
<Field
|
||||||
|
label="Title Template"
|
||||||
|
value={titleTemplate}
|
||||||
|
onChange={setTitleTemplate}
|
||||||
|
placeholder="INC-001 (leave empty to disable)"
|
||||||
|
/>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: Colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 10,
|
||||||
|
marginTop: -8,
|
||||||
|
marginBottom: 14,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: Colors.text2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
|
||||||
|
Pre-fills the Title field on new incidents. If the value ends with
|
||||||
|
digits, the number is auto-incremented after each save.{"\n"}
|
||||||
|
e.g. INC-001 → INC-002 → INC-003
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={SECTION_TITLE}>AI — Root Cause Assistant</Text>
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: Colors.surface,
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: 12,
|
||||||
|
marginBottom: 14,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: Colors.text2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
|
||||||
|
When enabled, a button appears on the capture form to suggest a root
|
||||||
|
cause and fix based on the incident title and symptom. Uses any
|
||||||
|
OpenAI-compatible API — set the base URL below to point at a local
|
||||||
|
Ollama instance, or leave empty for OpenAI (api.openai.com).
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<ToggleRow
|
<ToggleRow
|
||||||
label="Enable AI assistance"
|
label="Enable AI assistance"
|
||||||
value={aiEnabled}
|
value={aiEnabled}
|
||||||
onValueChange={setAiEnabled}
|
onValueChange={setAiEnabled}
|
||||||
/>
|
/>
|
||||||
{aiEnabled && (
|
{aiEnabled && (
|
||||||
<Field
|
<>
|
||||||
label="API Key"
|
<Field
|
||||||
value={aiKey}
|
label="API Base URL"
|
||||||
onChange={setAiKey}
|
value={aiBaseUrl}
|
||||||
placeholder="sk-..."
|
onChange={setAiBaseUrl}
|
||||||
secure
|
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)"
|
||||||
/>
|
url
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="Model"
|
||||||
|
value={aiModel}
|
||||||
|
onChange={setAiModel}
|
||||||
|
placeholder="gpt-4o-mini or llama3.2"
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
label="API Key"
|
||||||
|
value={aiKey}
|
||||||
|
onChange={setAiKey}
|
||||||
|
placeholder="sk-... (leave empty for Ollama)"
|
||||||
|
secure
|
||||||
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Text style={SECTION_TITLE}>Git Repository</Text>
|
<Text style={SECTION_TITLE}>Git Repository</Text>
|
||||||
|
|||||||
+72
-19
@@ -17,6 +17,16 @@ export interface PushResult {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
return fetch(url, { ...init, signal: controller.signal }).finally(() =>
|
||||||
|
clearTimeout(timer)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export async function pushIncidentToGit(
|
export async function pushIncidentToGit(
|
||||||
incident: Incident,
|
incident: Incident,
|
||||||
config: GitConfig,
|
config: GitConfig,
|
||||||
@@ -37,43 +47,61 @@ async function pushGitea(
|
|||||||
config: GitConfig,
|
config: GitConfig,
|
||||||
markdownTemplate?: string
|
markdownTemplate?: string
|
||||||
): Promise<PushResult> {
|
): Promise<PushResult> {
|
||||||
|
if (!config.url) {
|
||||||
|
return { success: false, error: "Gitea instance URL is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
if (!config.token) {
|
||||||
|
return { success: false, error: "Gitea token is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
if (!config.owner || !config.repo) {
|
||||||
|
return { success: false, error: "Gitea owner or repository is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
|
||||||
const filepath = incidentToFilename(incident);
|
const filepath = incidentToFilename(incident);
|
||||||
const content = renderMarkdown(incident, markdownTemplate);
|
const content = renderMarkdown(incident, markdownTemplate);
|
||||||
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
||||||
const apiBase = (config.url ?? "").replace(/\/$/, "");
|
const apiBase = config.url.replace(/\/$/, "");
|
||||||
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
|
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await fetch(endpoint, {
|
const existing = await fetchWithTimeout(endpoint, {
|
||||||
headers: { Authorization: `Bearer ${config.token}` },
|
headers: { Authorization: `token ${config.token}` },
|
||||||
});
|
});
|
||||||
let sha: string | undefined;
|
let sha: string | undefined;
|
||||||
if (existing.ok) {
|
if (existing.ok) {
|
||||||
const data = await existing.json() as { sha?: string };
|
const data = await existing.json() as { sha?: string };
|
||||||
sha = data.sha;
|
sha = data.sha;
|
||||||
|
} else if (existing.status !== 404) {
|
||||||
|
const body = await existing.text();
|
||||||
|
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: Record<string, string> = {
|
const payload: Record<string, string> = {
|
||||||
message: `incident: ${incident.title}`,
|
message: `incident: ${incident.title}`,
|
||||||
content: base64Content,
|
content: base64Content,
|
||||||
};
|
};
|
||||||
if (sha) body.sha = sha;
|
if (sha) payload.sha = sha;
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
// POST creates, PUT updates — Gitea requires the correct verb
|
||||||
method: "POST",
|
const res = await fetchWithTimeout(endpoint, {
|
||||||
|
method: sha ? "PUT" : "POST",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${config.token}`,
|
Authorization: `token ${config.token}`,
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
const body = await res.text();
|
||||||
|
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
const data = await res.json() as { content?: { html_url?: string } };
|
const data = await res.json() as { content?: { html_url?: string } };
|
||||||
return { success: true, url: data.content?.html_url };
|
return { success: true, url: data.content?.html_url };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === "AbortError") {
|
||||||
|
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s. Check that the Gitea URL is reachable.` };
|
||||||
|
}
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,13 +111,20 @@ async function pushGitHub(
|
|||||||
config: GitConfig,
|
config: GitConfig,
|
||||||
markdownTemplate?: string
|
markdownTemplate?: string
|
||||||
): Promise<PushResult> {
|
): Promise<PushResult> {
|
||||||
|
if (!config.token) {
|
||||||
|
return { success: false, error: "GitHub token is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
if (!config.owner || !config.repo) {
|
||||||
|
return { success: false, error: "GitHub owner or repository is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
|
||||||
const filepath = incidentToFilename(incident);
|
const filepath = incidentToFilename(incident);
|
||||||
const content = renderMarkdown(incident, markdownTemplate);
|
const content = renderMarkdown(incident, markdownTemplate);
|
||||||
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
||||||
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
|
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await fetch(endpoint, {
|
const existing = await fetchWithTimeout(endpoint, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${config.token}`,
|
Authorization: `Bearer ${config.token}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
@@ -99,30 +134,37 @@ async function pushGitHub(
|
|||||||
if (existing.ok) {
|
if (existing.ok) {
|
||||||
const data = await existing.json() as { sha?: string };
|
const data = await existing.json() as { sha?: string };
|
||||||
sha = data.sha;
|
sha = data.sha;
|
||||||
|
} else if (existing.status !== 404) {
|
||||||
|
const body = await existing.text();
|
||||||
|
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: Record<string, string> = {
|
const payload: Record<string, string> = {
|
||||||
message: `incident: ${incident.title}`,
|
message: `incident: ${incident.title}`,
|
||||||
content: base64Content,
|
content: base64Content,
|
||||||
};
|
};
|
||||||
if (sha) body.sha = sha;
|
if (sha) payload.sha = sha;
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetchWithTimeout(endpoint, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${config.token}`,
|
Authorization: `Bearer ${config.token}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
const body = await res.text();
|
||||||
|
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
const data = await res.json() as { content?: { html_url?: string } };
|
const data = await res.json() as { content?: { html_url?: string } };
|
||||||
return { success: true, url: data.content?.html_url };
|
return { success: true, url: data.content?.html_url };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === "AbortError") {
|
||||||
|
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
|
||||||
|
}
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,6 +174,13 @@ async function pushGitLab(
|
|||||||
config: GitConfig,
|
config: GitConfig,
|
||||||
markdownTemplate?: string
|
markdownTemplate?: string
|
||||||
): Promise<PushResult> {
|
): Promise<PushResult> {
|
||||||
|
if (!config.token) {
|
||||||
|
return { success: false, error: "GitLab token is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
if (!config.owner || !config.repo) {
|
||||||
|
return { success: false, error: "GitLab namespace or repository is not set. Check Settings → Git Repository." };
|
||||||
|
}
|
||||||
|
|
||||||
const filepath = incidentToFilename(incident);
|
const filepath = incidentToFilename(incident);
|
||||||
const content = renderMarkdown(incident, markdownTemplate);
|
const content = renderMarkdown(incident, markdownTemplate);
|
||||||
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
const base64Content = btoa(unescape(encodeURIComponent(content)));
|
||||||
@@ -141,12 +190,12 @@ async function pushGitLab(
|
|||||||
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
|
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existing = await fetch(`${endpoint}?ref=HEAD`, {
|
const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
|
||||||
headers: { "PRIVATE-TOKEN": config.token },
|
headers: { "PRIVATE-TOKEN": config.token },
|
||||||
});
|
});
|
||||||
const method = existing.ok ? "PUT" : "POST";
|
const method = existing.ok ? "PUT" : "POST";
|
||||||
|
|
||||||
const res = await fetch(endpoint, {
|
const res = await fetchWithTimeout(endpoint, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
"PRIVATE-TOKEN": config.token,
|
"PRIVATE-TOKEN": config.token,
|
||||||
@@ -161,7 +210,8 @@ async function pushGitLab(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
const body = await res.text();
|
||||||
|
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
|
||||||
}
|
}
|
||||||
const data = await res.json() as { file_path?: string };
|
const data = await res.json() as { file_path?: string };
|
||||||
const webUrl = data.file_path
|
const webUrl = data.file_path
|
||||||
@@ -169,6 +219,9 @@ async function pushGitLab(
|
|||||||
: undefined;
|
: undefined;
|
||||||
return { success: true, url: webUrl };
|
return { success: true, url: webUrl };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.name === "AbortError") {
|
||||||
|
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
|
||||||
|
}
|
||||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user