feat(components): improve AboutPage, CallPage, DebugLogsPage, and SettingsPage with improved layout, error handling, and clipboard functionality; add tests for environment path fallbacks and log copying

This commit is contained in:
Ivan committed 2026-04-16 03:20:24 -05:00
1 parent 213b88979e
commit 63fca0c1b1
7 files changed
+183 -51

No files matched your search

@@ -1,9 +1,9 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6 text-gray-900 dark:text-zinc-100"
>
<div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24">
<div class="space-y-0 w-full max-w-4xl mx-auto pb-16 sm:pb-24 min-w-0">
<div v-if="appInfo" class="about-section">
<div class="flex flex-col gap-8 lg:flex-row lg:items-center">
<!-- Logo & Title -->
@@ -271,13 +271,13 @@
<v-icon icon="mdi-server" size="14"></v-icon>
Environment Information
</div>
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm">
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3 text-sm min-w-0">
<div>
<div class="glass-label !text-[10px] mb-2 opacity-50">Reticulum Config</div>
<div
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
{{ appInfo.reticulum_config_path }}
{{ appInfo.reticulum_config_path || "unknown" }}
</div>
<button
v-if="isElectron"
@@ -293,7 +293,7 @@
<div
class="monospace-field !bg-zinc-50 dark:!bg-zinc-950 break-all text-[11px] !p-3 rounded-xl border border-zinc-100 dark:border-zinc-800"
>
{{ appInfo.database_path }}
{{ appInfo.database_path || "unknown" }}
</div>
<button
v-if="isElectron"
@@ -390,8 +390,8 @@
<v-icon icon="mdi-link-variant" size="14"></v-icon>
Dependency Chain
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 relative">
<div class="flex flex-col space-y-8">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 relative min-w-0">
<div class="flex flex-col space-y-8 min-w-0">
<div class="flex items-center gap-5">
<div
class="w-12 h-12 rounded-2xl bg-blue-500/10 flex items-center justify-center border border-blue-500/20 shadow-sm"
@@ -463,8 +463,8 @@
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
Reticulum Network Stack
</div>
<div class="flex items-center gap-2 mt-1">
<div class="text-xs font-mono font-bold text-gray-400">
<div class="flex flex-wrap items-center gap-2 mt-1 min-w-0">
<div class="text-xs font-mono font-bold text-gray-400 shrink-0">
v{{ appInfo.rns_version }}
</div>
<div
@@ -473,7 +473,7 @@
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
]"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border max-w-full break-words"
>
{{
appInfo.is_connected_to_shared_instance
@@ -486,9 +486,9 @@
</div>
</div>
<div class="space-y-8">
<div class="space-y-8 min-w-0">
<div
class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02]"
class="py-4 sm:p-5 border-t border-gray-200/60 dark:border-zinc-800/80 sm:border sm:rounded-2xl sm:bg-black/[0.02] dark:sm:bg-white/[0.02] min-w-0"
>
<div
class="text-[10px] font-black text-gray-400 dark:text-zinc-600 uppercase tracking-[0.2em] mb-4"
@@ -962,11 +962,36 @@ export default {
}
},
environmentInfo() {
const ua = typeof navigator !== "undefined" ? navigator.userAgent || "" : "";
let platform = typeof navigator !== "undefined" && navigator.platform ? navigator.platform : "";
if (
!platform &&
typeof navigator !== "undefined" &&
navigator.userAgentData &&
navigator.userAgentData.platform
) {
platform = navigator.userAgentData.platform;
}
if (!platform && /Android/i.test(ua)) {
platform = "Android";
}
if (!platform && this.appInfo && this.appInfo.host_platform) {
platform = this.appInfo.host_platform;
}
if (!platform) {
platform = "unknown";
}
const language =
typeof navigator !== "undefined" && navigator.language
? navigator.language
: typeof navigator !== "undefined" && navigator.languages && navigator.languages[0]
? navigator.languages[0]
: "unknown";
return {
platform: navigator?.platform || "unknown",
language: navigator?.language || "unknown",
userAgent: navigator?.userAgent || "unknown",
backendUrl: window?.location?.origin || "unknown",
platform,
language,
userAgent: ua || "unknown",
backendUrl: typeof window !== "undefined" && window.location ? window.location.origin : "unknown",
};
},
},
@@ -2411,15 +2411,14 @@ export default {
},
getMediaDevicesApi() {
const mediaDevices = navigator?.mediaDevices;
if (
!mediaDevices ||
typeof mediaDevices.getUserMedia !== "function" ||
typeof mediaDevices.enumerateDevices !== "function"
) {
if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") {
return null;
}
return mediaDevices;
},
hasEnumerateDevicesApi(mediaDevices) {
return Boolean(mediaDevices && typeof mediaDevices.enumerateDevices === "function");
},
getAudioContextConstructor() {
return window.AudioContext || window.webkitAudioContext || null;
},
@@ -2501,17 +2500,10 @@ export default {
return;
}
await this.refreshAudioDevices();
const hasInputDevices = (this.audioInputDevices || []).length > 0;
if (!hasInputDevices) {
await this.disableWebAudioBridgeWithError(
"call.no_audio_input_found",
new Error("No audio input devices detected"),
"start-no-input-devices"
);
return;
}
const validDeviceIds = new Set((this.audioInputDevices || []).map((d) => d.deviceId));
const canEnumerate = this.hasEnumerateDevicesApi(mediaDevices);
const validDeviceIds = canEnumerate
? new Set((this.audioInputDevices || []).map((d) => d.deviceId))
: new Set();
const hasSelectedDevice = this.selectedAudioInputId && validDeviceIds.has(this.selectedAudioInputId);
const constraints = hasSelectedDevice
? { audio: { deviceId: { exact: this.selectedAudioInputId } } }
@@ -2583,11 +2575,13 @@ export default {
if (!mediaDevices) {
throw new Error("navigator.mediaDevices is unavailable");
}
const devices = await mediaDevices.enumerateDevices();
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
if (devices.length > 0 && !hasAudioInput) {
ToastUtils.error(this.$t("call.no_audio_input_found"));
return false;
if (this.hasEnumerateDevicesApi(mediaDevices)) {
const devices = await mediaDevices.enumerateDevices();
const hasAudioInput = devices.some((d) => d.kind === "audioinput");
if (devices.length > 0 && !hasAudioInput) {
ToastUtils.error(this.$t("call.no_audio_input_found"));
return false;
}
}
const constraints = this.selectedAudioInputId
@@ -2617,6 +2611,11 @@ export default {
this.audioOutputDevices = [];
return;
}
if (!this.hasEnumerateDevicesApi(mediaDevices)) {
this.audioInputDevices = [];
this.audioOutputDevices = [];
return;
}
const devices = await mediaDevices.enumerateDevices();
this.audioInputDevices = devices.filter((d) => d.kind === "audioinput");
this.audioOutputDevices = devices.filter((d) => d.kind === "audiooutput");
@@ -1,7 +1,7 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-slate-50 dark:bg-zinc-950">
<div
class="flex flex-col h-full overflow-hidden w-full px-4 md:px-5 lg:px-8 py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
class="flex flex-col h-full overflow-hidden w-full px-3 sm:px-4 md:px-5 lg:px-8 py-3 sm:py-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div class="flex flex-col mb-4 w-full max-w-6xl mx-auto space-y-4 min-w-0">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between min-w-0">
@@ -131,7 +131,7 @@
<div class="flex-1 overflow-hidden glass-card max-w-6xl mx-auto w-full p-0 flex flex-col rounded-sm">
<div
v-if="activeTab === 'logs'"
class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
>
<div v-if="logs.length === 0" class="text-gray-500 italic text-center py-10">
{{ loading ? $t("debug.loading_logs") : $t("debug.no_logs") }}
@@ -139,14 +139,16 @@
<div
v-for="(log, index) in logs"
:key="index"
class="border-b border-gray-100 dark:border-zinc-900 py-1 flex gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
class="border-b border-gray-100 dark:border-zinc-900 py-1.5 flex gap-2 sm:gap-3 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
:class="{ 'bg-red-50/30 dark:bg-red-900/10': log.is_anomaly }"
title="Tap to copy this log entry"
@click="copyLogLine(log)"
>
<span class="text-gray-400 shrink-0">{{ formatTime(log.timestamp) }}</span>
<span :class="levelClass(log.level)" class="w-12 shrink-0 font-bold uppercase">{{
log.level
}}</span>
<span class="text-blue-500 shrink-0 w-24 overflow-hidden text-ellipsis italic"
<span class="text-blue-500 shrink-0 w-20 sm:w-24 overflow-hidden text-ellipsis italic"
>[{{ log.module }}]</span
>
<span class="text-gray-800 dark:text-gray-200 break-words flex-1">
@@ -164,7 +166,7 @@
<div
v-else
class="flex-1 overflow-auto p-4 font-mono text-[10px] sm:text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
class="flex-1 overflow-auto p-3 sm:p-4 font-mono text-xs leading-relaxed select-text bg-white dark:bg-zinc-950"
>
<div v-if="accessAttempts.length === 0" class="text-gray-500 italic text-center py-10">
{{ accessLoading ? $t("debug.loading_access") : $t("debug.no_access") }}
@@ -172,9 +174,11 @@
<div
v-for="row in accessAttempts"
:key="row.id"
class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50"
class="border-b border-gray-100 dark:border-zinc-900 py-2 flex flex-col gap-1 hover:bg-gray-50 dark:hover:bg-zinc-900/50 cursor-copy"
title="Tap to copy this access entry"
@click="copyAccessLine(row)"
>
<div class="flex flex-wrap gap-x-3 gap-y-1">
<div class="flex flex-wrap gap-x-3 gap-y-1 items-center">
<span class="text-gray-400 shrink-0">{{ formatTime(row.created_at) }}</span>
<span class="text-amber-600 dark:text-amber-400 font-semibold">{{ row.outcome }}</span>
<span class="text-cyan-600 dark:text-cyan-400">{{ row.method }} {{ row.path }}</span>
@@ -441,6 +445,32 @@ export default {
}
}
},
async copyLogLine(log) {
const line = `${this.formatTime(log.timestamp)} [${log.level}] [${log.module}] ${log.message}${log.is_anomaly ? " [ANOMALY:" + (log.anomaly_type || "unknown") + "]" : ""}`;
try {
await navigator.clipboard.writeText(line);
ToastUtils.success(this.$t("debug.logs_copied"));
} catch {
ToastUtils.error(this.$t("debug.failed_copy_logs"));
}
},
async copyAccessLine(row) {
const line = [
this.formatTime(row.created_at),
row.outcome,
row.method,
row.path,
row.client_ip,
row.user_agent || "",
row.detail || "",
].join(" | ");
try {
await navigator.clipboard.writeText(line);
ToastUtils.success(this.$t("debug.access_copied"));
} catch {
ToastUtils.error(this.$t("debug.failed_copy_access"));
}
},
},
};
</script>
@@ -4727,7 +4727,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
DialogUtils.alert(this.$t("messages.failed_start_recording"));
DialogUtils.alert(this.buildAudioRecordingFailureMessage());
}
break;
@@ -4749,7 +4749,7 @@ export default {
// alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
DialogUtils.alert(this.$t("messages.failed_start_recording"));
DialogUtils.alert(this.buildAudioRecordingFailureMessage());
}
break;
@@ -4843,6 +4843,15 @@ export default {
// remove audio
this.newMessageAudio = null;
},
buildAudioRecordingFailureMessage() {
if (!navigator?.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== "function") {
return `${this.$t("messages.failed_start_recording")} (microphone API unavailable in this webview context)`;
}
if (typeof MediaRecorder !== "function") {
return `${this.$t("messages.failed_start_recording")} (MediaRecorder not supported on this device)`;
}
return this.$t("messages.failed_start_recording");
},
removeFileAttachment: function (file) {
this.newMessageFiles = this.newMessageFiles.filter((newMessageFile) => {
return newMessageFile !== file;
@@ -3,8 +3,8 @@
v-if="config"
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
<div class="flex-1 overflow-y-auto overflow-x-hidden w-full min-w-0 px-3 sm:px-5 md:px-5 lg:px-8 py-4 sm:py-6">
<div class="space-y-0 w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0">
<div class="settings-section settings-section--hero">
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
<div class="flex-1 space-y-1">
@@ -98,9 +98,9 @@
<!-- search bar -->
<div
class="sticky top-0 z-10 py-3 sm:py-4 -mx-3 sm:mx-0 px-3 sm:px-0 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent"
class="sticky top-0 z-10 py-3 sm:py-4 mb-2 border-b border-gray-200/50 dark:border-zinc-800/50 bg-transparent min-w-0"
>
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto">
<div class="relative w-full max-w-6xl xl:max-w-7xl 2xl:max-w-[90rem] mx-auto min-w-0 px-0">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
+31
View File
@@ -271,4 +271,35 @@ describe("AboutPage.vue", () => {
expect(wrapper.text()).toContain("Free Space");
expect(wrapper.text()).toContain("0 Bytes");
});
it("shows unknown fallbacks for missing environment paths", async () => {
axiosMock.get.mockImplementation((url) => {
if (url === "/api/v1/app/info")
return Promise.resolve({
data: {
app_info: {
version: "1.0.0",
python_version: "3.11.0",
lxmf_version: "0.2.0",
rns_version: "0.1.0",
reticulum_config_path: null,
database_path: null,
},
},
});
if (url === "/api/v1/config") return Promise.resolve({ data: { config: {} } });
if (url === "/api/v1/database/health") return Promise.resolve({ data: { database: {} } });
if (url === "/api/v1/database/snapshots") return Promise.resolve({ data: [] });
return Promise.reject(new Error("Not found"));
});
const wrapper = mountAboutPage();
await vi.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain("Reticulum Config");
expect(wrapper.text()).toContain("Database Path");
expect(wrapper.text()).toContain("unknown");
});
});
+39 -1
View File
@@ -10,6 +10,11 @@ window.api = {
describe("DebugLogsPage.vue", () => {
beforeEach(() => {
vi.clearAllMocks();
Object.assign(navigator, {
clipboard: {
writeText: vi.fn().mockResolvedValue(undefined),
},
});
});
it("fetches and displays logs", async () => {
@@ -44,7 +49,7 @@ describe("DebugLogsPage.vue", () => {
await new Promise((resolve) => setTimeout(resolve, 10));
await wrapper.vm.$nextTick();
const container = wrapper.find(".flex-1.overflow-auto.p-4.font-mono");
const container = wrapper.find(".flex-1.overflow-auto.font-mono");
const logRows = container.findAll(".border-b");
expect(logRows.length).toBe(2);
expect(wrapper.text()).toContain("Hello");
@@ -160,4 +165,37 @@ describe("DebugLogsPage.vue", () => {
expect(wrapper.text()).toContain("failed_password");
expect(wrapper.text()).toContain("10.0.0.1");
});
it("copies a single log line when tapping an entry", async () => {
const mockLogs = [
{ timestamp: Date.now() / 1000, level: "ERROR", module: "web_protocol", message: "Boom", is_anomaly: 0 },
];
window.api.get.mockResolvedValue({
data: {
logs: mockLogs,
total: 1,
limit: 100,
offset: 0,
},
});
const wrapper = mount(DebugLogsPage, {
global: {
stubs: ["MaterialDesignIcon"],
mocks: { $t: (key) => key },
},
});
await new Promise((resolve) => setTimeout(resolve, 10));
await wrapper.vm.$nextTick();
const row = wrapper.findAll(".border-b").find((r) => r.text().includes("Boom"));
expect(row).toBeTruthy();
await row.trigger("click");
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("[ERROR]");
expect(navigator.clipboard.writeText.mock.calls[0][0]).toContain("Boom");
});
});