mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-08-28 09:14:10 +00:00
feat(repository-server): add repository server navigation and enhance conversation message entry with map link support
This commit is contained in:
@@ -257,6 +257,14 @@ export default {
|
||||
type: "navigation",
|
||||
route: { name: "documentation" },
|
||||
},
|
||||
{
|
||||
id: "nav-repository-server",
|
||||
title: "nav_repository_server",
|
||||
description: "nav_repository_server_desc",
|
||||
icon: "package-variant",
|
||||
type: "navigation",
|
||||
route: { name: "repository-server" },
|
||||
},
|
||||
{
|
||||
id: "nav-micron-editor",
|
||||
title: "nav_micron_editor",
|
||||
|
||||
@@ -500,6 +500,7 @@
|
||||
v-if="
|
||||
chatItem.lxmf_message.content &&
|
||||
!cv.getParsedItems(chatItem)?.isOnlyPaperMessage &&
|
||||
!cv.getParsedItems(chatItem)?.isOnlyMapLink &&
|
||||
!cv.shouldHideAutoImageCaption(chatItem)
|
||||
"
|
||||
class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
|
||||
@@ -635,6 +636,39 @@
|
||||
Ingest Message
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="cv.getParsedItems(chatItem).mapLink"
|
||||
class="flex flex-col gap-2 p-3 rounded-xl bg-sky-50 dark:bg-sky-950/30 border border-sky-200 dark:border-sky-800/50"
|
||||
>
|
||||
<div class="flex items-center gap-2 text-sky-800 dark:text-sky-300">
|
||||
<MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
|
||||
<span class="text-sm font-bold">{{
|
||||
cv.getParsedItems(chatItem).mapLink.kind === "ping"
|
||||
? $t("messages.map_link_ping_title")
|
||||
: $t("messages.map_link_share_title")
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="text-[10px] font-mono text-sky-900/80 dark:text-sky-200/90 break-all">
|
||||
{{ cv.getParsedItems(chatItem).mapLink.parsed.lat.toFixed(5) }},
|
||||
{{ cv.getParsedItems(chatItem).mapLink.parsed.lon.toFixed(5) }}
|
||||
(z{{ cv.getParsedItems(chatItem).mapLink.parsed.zoom }})
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2 bg-sky-600 hover:bg-sky-700 text-white rounded-lg text-xs font-bold transition-colors shadow-sm"
|
||||
@click="cv.openMapShareFromParsed(cv.getParsedItems(chatItem).mapLink.parsed)"
|
||||
>
|
||||
{{ $t("messages.map_link_open") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full py-2 bg-white dark:bg-zinc-900 border border-sky-200 dark:border-sky-800 text-sky-800 dark:text-sky-200 rounded-lg text-xs font-bold"
|
||||
@click="cv.copyMapShareUri(cv.getParsedItems(chatItem).mapLink.uri)"
|
||||
>
|
||||
{{ $t("messages.map_link_copy_uri") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio field -->
|
||||
|
||||
@@ -45,143 +45,14 @@
|
||||
@close="close"
|
||||
/>
|
||||
|
||||
<!-- Telemetry History Modal -->
|
||||
<div
|
||||
v-if="isTelemetryHistoryModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500" />
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Telemetry History</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
|
||||
@click="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div v-if="selectedPeerTelemetryItems.length === 0" class="text-center py-8 text-gray-400">
|
||||
No telemetry history found for this peer.
|
||||
</div>
|
||||
<div
|
||||
v-for="item in selectedPeerTelemetryItems"
|
||||
:key="item.lxmf_message.hash"
|
||||
class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
|
||||
>
|
||||
{{ item.is_outbound ? "Sent" : "Received" }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400">{{
|
||||
formatTimeAgo(item.lxmf_message.created_at)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- location -->
|
||||
<div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
@click="viewLocationOnMap(item.lxmf_message.fields.telemetry.location)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker" class="size-4" />
|
||||
{{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
|
||||
{{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- sensors -->
|
||||
<div
|
||||
v-if="item.lxmf_message.fields?.telemetry"
|
||||
class="flex flex-wrap gap-3 text-[10px] text-gray-500"
|
||||
>
|
||||
<span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
|
||||
<MaterialDesignIcon icon-name="battery" class="size-3" />
|
||||
Battery: {{ item.lxmf_message.fields.telemetry.battery.charge_percent }}%
|
||||
</span>
|
||||
<span
|
||||
v-if="item.lxmf_message.fields.telemetry.physical_link"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="antenna" class="size-3" />
|
||||
SNR: {{ item.lxmf_message.fields.telemetry.physical_link.snr }}dB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- commands -->
|
||||
<div
|
||||
v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
|
||||
class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
|
||||
<span>Location Request</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 bg-gray-50/30 dark:bg-zinc-900/20 flex flex-col gap-4"
|
||||
>
|
||||
<div v-if="telemetryBatteryHistory.length > 1" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
|
||||
>Battery Level (%)</span
|
||||
>
|
||||
<span class="text-[10px] text-gray-500"
|
||||
>{{ telemetryBatteryHistory[0].y }}% →
|
||||
{{ telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y }}%</span
|
||||
>
|
||||
</div>
|
||||
<svg class="w-full h-12 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<path
|
||||
:d="batterySparklinePath"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
:cx="100"
|
||||
:cy="100 - telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y"
|
||||
r="3"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
v-model="showTelemetryInChat"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
|
||||
>Show telemetry in main chat</span
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
||||
@click="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TelemetryHistoryModal
|
||||
v-model="isTelemetryHistoryModalOpen"
|
||||
v-model:show-telemetry-in-chat="showTelemetryInChat"
|
||||
:telemetry-items="selectedPeerTelemetryItems"
|
||||
:format-time-ago="formatTimeAgo"
|
||||
:gradient-id-suffix="telemetryModalGradientSuffix"
|
||||
@location-click="viewLocationOnMap"
|
||||
/>
|
||||
|
||||
<!-- Share Contact Modal -->
|
||||
<div
|
||||
@@ -1650,12 +1521,14 @@ import ToastUtils from "../../js/ToastUtils";
|
||||
import PaperMessageModal from "./PaperMessageModal.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||
import { findMapUriInContent, mapLinkKindFromMessage, parseMeshchatMapUri } from "../../js/mapLinkUtils.js";
|
||||
import { COLUMBA_REACTION_EMOJIS, mergeLxmfReactionRowsIntoMessages } from "../../js/lxmfReactions";
|
||||
import { createOutboundQueue } from "../../js/outboundSendQueue";
|
||||
import emojiPickerEnDataUrl from "emoji-picker-element-data/en/emojibase/data.json?url";
|
||||
import "emoji-picker-element";
|
||||
import StickerView from "../stickers/StickerView.vue";
|
||||
import InViewAnimatedImg from "./InViewAnimatedImg.vue";
|
||||
import TelemetryHistoryModal from "./telemetry/TelemetryHistoryModal.vue";
|
||||
|
||||
export default {
|
||||
name: "ConversationViewer",
|
||||
@@ -1675,6 +1548,7 @@ export default {
|
||||
ConversationMessageListVirtual,
|
||||
StickerView,
|
||||
InViewAnimatedImg,
|
||||
TelemetryHistoryModal,
|
||||
},
|
||||
props: {
|
||||
config: {
|
||||
@@ -2112,30 +1986,9 @@ export default {
|
||||
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
|
||||
);
|
||||
},
|
||||
telemetryBatteryHistory() {
|
||||
return this.selectedPeerTelemetryItems
|
||||
.filter((item) => item.lxmf_message.fields?.telemetry?.battery)
|
||||
.map((item) => ({
|
||||
x: item.lxmf_message.timestamp,
|
||||
y: item.lxmf_message.fields.telemetry.battery.charge_percent,
|
||||
}))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
},
|
||||
batterySparklinePath() {
|
||||
const history = this.telemetryBatteryHistory;
|
||||
if (history.length < 2) return "";
|
||||
|
||||
const minX = history[0].x;
|
||||
const maxX = history[history.length - 1].x;
|
||||
const rangeX = maxX - minX || 1;
|
||||
|
||||
return history
|
||||
.map((p, i) => {
|
||||
const x = ((p.x - minX) / rangeX) * 100;
|
||||
const y = 100 - p.y; // SVG y is top-down
|
||||
return `${i === 0 ? "M" : "L"} ${x} ${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
telemetryModalGradientSuffix() {
|
||||
const h = this.selectedPeer?.destination_hash || "default";
|
||||
return h.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "default";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
@@ -2292,6 +2145,9 @@ export default {
|
||||
if (this.getParsedItems(chatItem)?.isOnlyPaperMessage) {
|
||||
return false;
|
||||
}
|
||||
if (this.getParsedItems(chatItem)?.isOnlyMapLink) {
|
||||
return false;
|
||||
}
|
||||
if (this.shouldHideAutoImageCaption(chatItem)) {
|
||||
return false;
|
||||
}
|
||||
@@ -2652,6 +2508,7 @@ export default {
|
||||
const items = {
|
||||
contact: null,
|
||||
paperMessage: null,
|
||||
mapLink: null,
|
||||
};
|
||||
|
||||
// Parse contact: Contact: ivan <ca314c30b27eacec5f6ca6ac504e94c9> [LXMF: ...] [LXST: ...]
|
||||
@@ -2694,6 +2551,23 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
const mapUri = findMapUriInContent(content);
|
||||
if (mapUri && !items.paperMessage) {
|
||||
const parsed = parseMeshchatMapUri(mapUri);
|
||||
if (parsed) {
|
||||
const kind = mapLinkKindFromMessage(content, parsed);
|
||||
let t = content.trim().replace(mapUri, "").trim();
|
||||
t = t
|
||||
.replace(/^MeshChatX\s+map\s+ping:\s*/i, "")
|
||||
.replace(/^MeshChatX\s+map:\s*/i, "")
|
||||
.trim();
|
||||
items.mapLink = { uri: mapUri, parsed, kind };
|
||||
if (t === "") {
|
||||
items.isOnlyMapLink = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
},
|
||||
async addContact(name, hash, lxmf_address = null, lxst_address = null) {
|
||||
@@ -4486,6 +4360,38 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
openMapShareFromParsed(parsed) {
|
||||
if (!parsed) {
|
||||
return;
|
||||
}
|
||||
const query = {
|
||||
lat: String(parsed.lat),
|
||||
lon: String(parsed.lon),
|
||||
zoom: String(parsed.zoom),
|
||||
};
|
||||
if (parsed.layers) {
|
||||
query.layers = parsed.layers;
|
||||
}
|
||||
if (parsed.label) {
|
||||
query.label = parsed.label;
|
||||
}
|
||||
this.$router.push({
|
||||
name: "map",
|
||||
query,
|
||||
});
|
||||
},
|
||||
async copyMapShareUri(uri) {
|
||||
if (!uri) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(uri);
|
||||
ToastUtils.success(this.$t("messages.map_link_copied"));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error(this.$t("messages.failed_copy_address"));
|
||||
}
|
||||
},
|
||||
isTelemetryOnly(msg) {
|
||||
return isTelemetryOnlyMessage(msg);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,491 @@
|
||||
<!-- SPDX-License-Identifier: 0BSD -->
|
||||
|
||||
<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 pb-[max(1rem,env(safe-area-inset-bottom))]">
|
||||
<div class="p-3 sm:p-4 md:p-6 max-w-6xl mx-auto w-full space-y-4 min-w-0">
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-200 dark:border-zinc-800 pb-4"
|
||||
>
|
||||
<div class="flex items-start gap-3 min-w-0">
|
||||
<div
|
||||
class="p-2 bg-sky-100 dark:bg-sky-900/30 text-sky-600 dark:text-sky-300 rounded-lg shrink-0"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="package-variant" class="size-6" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
</div>
|
||||
<h1 class="text-lg sm:text-xl font-bold text-gray-900 dark:text-white tracking-tight">
|
||||
{{ $t("tools.repository_server.title") }}
|
||||
</h1>
|
||||
<p class="text-xs sm:text-sm text-gray-600 dark:text-gray-400 mt-1 max-w-2xl">
|
||||
{{ $t("tools.repository_server.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center justify-center p-2 rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 text-gray-600 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
|
||||
:title="$t('tools.repository_server.refresh_bundled_tooltip')"
|
||||
:disabled="refreshing"
|
||||
@click="refreshBundled"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="download" class="size-5" />
|
||||
</button>
|
||||
<RouterLink
|
||||
to="/tools"
|
||||
class="inline-flex items-center gap-2 text-sm text-sky-600 dark:text-sky-300 hover:underline"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="arrow-left" class="size-4" />
|
||||
{{ $t("tools.repository_server.back_tools") }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="refreshing"
|
||||
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 px-3 py-2.5 space-y-1.5"
|
||||
>
|
||||
<div class="h-1.5 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full bg-sky-500 dark:bg-sky-400 rounded-full transition-[width] duration-300 ease-out"
|
||||
:style="{ width: refreshProgressPct + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="refreshProgressLabel" class="text-xs text-gray-600 dark:text-zinc-400 truncate font-mono">
|
||||
{{ refreshProgressLabel }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("tools.repository_server.http_heading") }}
|
||||
</h2>
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<label class="flex flex-col gap-1 text-xs min-w-[10rem]">
|
||||
<span class="text-gray-500 dark:text-zinc-500">{{
|
||||
$t("tools.repository_server.host_label")
|
||||
}}</span>
|
||||
<input
|
||||
v-model="httpHost"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-sm text-gray-900 dark:text-white"
|
||||
:disabled="httpBusy || loading || httpRunning"
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-xs w-24">
|
||||
<span class="text-gray-500 dark:text-zinc-500">{{
|
||||
$t("tools.repository_server.port_label")
|
||||
}}</span>
|
||||
<input
|
||||
v-model="httpPort"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
autocomplete="off"
|
||||
class="rounded-lg border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1.5 text-sm text-gray-900 dark:text-white"
|
||||
:disabled="httpBusy || loading || httpRunning"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg bg-emerald-600 text-white text-sm font-medium hover:bg-emerald-700 disabled:opacity-50 disabled:pointer-events-none"
|
||||
:disabled="httpBusy || loading || httpRunning"
|
||||
@click="startHttp"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="play" class="size-4" />
|
||||
{{ $t("tools.repository_server.start_http") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:pointer-events-none"
|
||||
:disabled="httpBusy || loading || !httpRunning"
|
||||
@click="stopHttp"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="stop" class="size-4" />
|
||||
{{ $t("tools.repository_server.stop_http") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-2 rounded-lg border border-gray-300 dark:border-zinc-600 bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200 text-sm font-medium hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:pointer-events-none"
|
||||
:disabled="httpBusy || loading"
|
||||
@click="restartHttp"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="restart" class="size-4" />
|
||||
{{ $t("tools.repository_server.restart_http") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="httpRunning && status?.http?.url" class="text-xs space-y-1">
|
||||
<div class="text-gray-600 dark:text-zinc-400">
|
||||
{{ $t("tools.repository_server.http_listen_label") }}
|
||||
<span class="font-mono text-gray-900 dark:text-zinc-200">{{ status.http.url }}</span>
|
||||
</div>
|
||||
<a
|
||||
v-if="browserRepoUrl"
|
||||
:href="browserRepoUrl"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="inline-flex items-center gap-1 text-sky-600 dark:text-sky-400 font-medium hover:underline"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="size-4" />
|
||||
{{ $t("tools.repository_server.open_http") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 p-4 space-y-3"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("tools.repository_server.upload_heading") }}
|
||||
</h2>
|
||||
<label
|
||||
class="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-sky-600 text-white text-sm font-medium cursor-pointer hover:bg-sky-700 transition-colors"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="upload" class="size-4" />
|
||||
{{ $t("tools.repository_server.choose_file") }}
|
||||
<input type="file" class="hidden" @change="onUpload" />
|
||||
</label>
|
||||
<p v-if="lastUploadError" class="text-xs text-red-600 dark:text-red-400">{{ lastUploadError }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status?.last_refresh_failed && Object.keys(status.last_refresh_failed).length"
|
||||
class="rounded-xl border border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-950/30 p-3 text-xs text-amber-900 dark:text-amber-200"
|
||||
>
|
||||
<div class="font-semibold mb-1">{{ $t("tools.repository_server.refresh_partial") }}</div>
|
||||
<ul class="list-disc pl-4 space-y-1">
|
||||
<li v-for="(msg, pkg) in status.last_refresh_failed" :key="pkg">
|
||||
<span class="font-mono">{{ pkg }}</span
|
||||
>: {{ msg }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-950 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="px-4 py-3 border-b border-gray-100 dark:border-zinc-800 flex justify-between items-center"
|
||||
>
|
||||
<h2 class="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("tools.repository_server.files_heading") }}
|
||||
</h2>
|
||||
<span class="text-xs text-gray-500">{{ entries.length }}</span>
|
||||
</div>
|
||||
<div v-if="loading" class="p-6 text-center text-sm text-gray-500">
|
||||
{{ $t("common.loading") }}
|
||||
</div>
|
||||
<div v-else-if="entries.length === 0" class="p-6 text-center text-sm text-gray-500">
|
||||
{{ $t("tools.repository_server.empty") }}
|
||||
</div>
|
||||
<table v-else class="w-full text-left text-xs">
|
||||
<thead class="bg-gray-50 dark:bg-zinc-900/50 text-gray-500 uppercase tracking-wide">
|
||||
<tr>
|
||||
<th class="px-4 py-2">{{ $t("tools.repository_server.col_name") }}</th>
|
||||
<th class="px-4 py-2">{{ $t("tools.repository_server.col_source") }}</th>
|
||||
<th class="px-4 py-2 text-right">{{ $t("tools.repository_server.col_size") }}</th>
|
||||
<th class="px-4 py-2 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 dark:divide-zinc-800 text-gray-800 dark:text-zinc-200">
|
||||
<tr v-for="row in entries" :key="row.name + row.source">
|
||||
<td class="px-4 py-2 font-mono break-all">{{ row.name }}</td>
|
||||
<td class="px-4 py-2">{{ row.source }}</td>
|
||||
<td class="px-4 py-2 text-right tabular-nums">{{ formatBytes(row.bytes) }}</td>
|
||||
<td class="px-4 py-2 text-right">
|
||||
<button
|
||||
v-if="row.source === 'upload'"
|
||||
type="button"
|
||||
class="text-red-600 dark:text-red-400 hover:underline text-xs font-medium"
|
||||
@click="deleteUpload(row.name)"
|
||||
>
|
||||
{{ $t("tools.repository_server.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
export default {
|
||||
name: "RepositoryServerPage",
|
||||
components: { MaterialDesignIcon },
|
||||
data() {
|
||||
return {
|
||||
loading: true,
|
||||
refreshing: false,
|
||||
httpBusy: false,
|
||||
httpHost: "127.0.0.1",
|
||||
httpPort: "8787",
|
||||
status: null,
|
||||
entries: [],
|
||||
lastUploadError: null,
|
||||
refreshPollId: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
refreshProgressPct() {
|
||||
const p = this.status?.refresh_progress;
|
||||
if (!p || !p.total) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(100, Math.round((100 * p.completed) / p.total));
|
||||
},
|
||||
refreshProgressLabel() {
|
||||
const p = this.status?.refresh_progress;
|
||||
if (!p?.current) {
|
||||
return "";
|
||||
}
|
||||
return this.$t("tools.repository_server.refresh_fetching", { pkg: p.current });
|
||||
},
|
||||
httpRunning() {
|
||||
return Boolean(this.status?.http?.running);
|
||||
},
|
||||
browserRepoUrl() {
|
||||
const raw = this.status?.http?.url;
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.hostname === "0.0.0.0" && typeof window !== "undefined" && window.location?.hostname) {
|
||||
parsed.hostname = window.location.hostname;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadAll();
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopRefreshPoll();
|
||||
},
|
||||
methods: {
|
||||
stopRefreshPoll() {
|
||||
if (this.refreshPollId != null) {
|
||||
clearInterval(this.refreshPollId);
|
||||
this.refreshPollId = null;
|
||||
}
|
||||
},
|
||||
syncHttpFormFromStatus() {
|
||||
const h = this.status?.http;
|
||||
if (!h) {
|
||||
return;
|
||||
}
|
||||
if (h.running) {
|
||||
if (h.host) {
|
||||
this.httpHost = h.host;
|
||||
}
|
||||
if (h.port != null) {
|
||||
this.httpPort = String(h.port);
|
||||
}
|
||||
} else if (h.last_host) {
|
||||
this.httpHost = h.last_host;
|
||||
this.httpPort = h.last_port != null ? String(h.last_port) : "8787";
|
||||
}
|
||||
},
|
||||
formatBytes(n) {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||
},
|
||||
httpErrorToast(errKey, detail) {
|
||||
const map = {
|
||||
invalid_host: this.$t("tools.repository_server.http_err_invalid_host"),
|
||||
invalid_port: this.$t("tools.repository_server.http_err_invalid_port"),
|
||||
already_running: this.$t("tools.repository_server.http_err_already_running"),
|
||||
bind_failed: this.$t("tools.repository_server.http_err_bind_failed"),
|
||||
};
|
||||
let msg = map[errKey] || this.$t("tools.repository_server.http_err_generic");
|
||||
if (errKey === "bind_failed" && detail) {
|
||||
msg = `${msg}: ${detail}`;
|
||||
} else if (!map[errKey] && detail) {
|
||||
msg = `${msg}: ${detail}`;
|
||||
}
|
||||
ToastUtils.error(msg);
|
||||
},
|
||||
async loadAll() {
|
||||
this.loading = true;
|
||||
this.lastUploadError = null;
|
||||
try {
|
||||
const [s, list] = await Promise.all([
|
||||
window.api.get("/api/v1/repository-server/status"),
|
||||
window.api.get("/api/v1/repository-server/list"),
|
||||
]);
|
||||
this.status = s.data;
|
||||
this.entries = Array.isArray(list.data) ? list.data : [];
|
||||
this.syncHttpFormFromStatus();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.load_failed"));
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
buildHttpBody() {
|
||||
const body = {};
|
||||
const host = (this.httpHost || "").trim();
|
||||
if (host) {
|
||||
body.host = host;
|
||||
}
|
||||
const portRaw = (this.httpPort || "").trim();
|
||||
if (portRaw !== "") {
|
||||
const p = parseInt(portRaw, 10);
|
||||
if (!Number.isFinite(p)) {
|
||||
return { error: "invalid_port" };
|
||||
}
|
||||
body.port = p;
|
||||
}
|
||||
return { body };
|
||||
},
|
||||
async startHttp() {
|
||||
const built = this.buildHttpBody();
|
||||
if (built.error === "invalid_port") {
|
||||
this.httpErrorToast("invalid_port");
|
||||
return;
|
||||
}
|
||||
this.httpBusy = true;
|
||||
try {
|
||||
const { data } = await window.api.post("/api/v1/repository-server/http/start", built.body || {});
|
||||
if (!data.ok) {
|
||||
this.httpErrorToast(data.error, data.message);
|
||||
} else {
|
||||
ToastUtils.success(this.$t("tools.repository_server.http_started"));
|
||||
}
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.httpBusy = false;
|
||||
}
|
||||
},
|
||||
async stopHttp() {
|
||||
this.httpBusy = true;
|
||||
try {
|
||||
await window.api.post("/api/v1/repository-server/http/stop");
|
||||
ToastUtils.success(this.$t("tools.repository_server.http_stopped"));
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.httpBusy = false;
|
||||
}
|
||||
},
|
||||
async restartHttp() {
|
||||
const built = this.buildHttpBody();
|
||||
if (built.error === "invalid_port") {
|
||||
this.httpErrorToast("invalid_port");
|
||||
return;
|
||||
}
|
||||
this.httpBusy = true;
|
||||
try {
|
||||
const { data } = await window.api.post("/api/v1/repository-server/http/restart", built.body || {});
|
||||
if (!data.ok) {
|
||||
this.httpErrorToast(data.error, data.message);
|
||||
} else {
|
||||
ToastUtils.success(this.$t("tools.repository_server.http_restarted"));
|
||||
}
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.http_err_generic"));
|
||||
console.error(e);
|
||||
} finally {
|
||||
this.httpBusy = false;
|
||||
}
|
||||
},
|
||||
async refreshBundled() {
|
||||
this.refreshing = true;
|
||||
ToastUtils.info(this.$t("tools.repository_server.refresh_started"));
|
||||
this.stopRefreshPoll();
|
||||
this.refreshPollId = setInterval(() => {
|
||||
window.api
|
||||
.get("/api/v1/repository-server/status")
|
||||
.then(({ data }) => {
|
||||
this.status = data;
|
||||
this.syncHttpFormFromStatus();
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 350);
|
||||
try {
|
||||
const { data } = await window.api.post("/api/v1/repository-server/refresh-bundled");
|
||||
if (data.error === "pip_not_found") {
|
||||
ToastUtils.error(this.$t("tools.repository_server.refresh_pip_missing"));
|
||||
await this.loadAll();
|
||||
return;
|
||||
}
|
||||
const nFail = data.failed ? Object.keys(data.failed).length : 0;
|
||||
const nOk = data.downloaded ? data.downloaded.length : 0;
|
||||
if (nFail > 0 && nOk === 0) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.refresh_failed"));
|
||||
} else if (nFail > 0) {
|
||||
ToastUtils.warning(this.$t("tools.repository_server.refresh_partial_toast"));
|
||||
ToastUtils.success(this.$t("tools.repository_server.refresh_ok_count", { n: nOk }));
|
||||
} else {
|
||||
ToastUtils.success(this.$t("tools.repository_server.refresh_ok"));
|
||||
}
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.refresh_failed"));
|
||||
console.error(e);
|
||||
await this.loadAll();
|
||||
} finally {
|
||||
this.stopRefreshPoll();
|
||||
this.refreshing = false;
|
||||
}
|
||||
},
|
||||
async onUpload(ev) {
|
||||
const input = ev.target;
|
||||
const file = input.files && input.files[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
this.lastUploadError = null;
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
try {
|
||||
await window.api.post("/api/v1/repository-server/upload", form);
|
||||
ToastUtils.success(this.$t("tools.repository_server.upload_ok"));
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
this.lastUploadError =
|
||||
e.response?.data?.error || e.message || this.$t("tools.repository_server.upload_failed");
|
||||
ToastUtils.error(this.$t("tools.repository_server.upload_failed"));
|
||||
}
|
||||
},
|
||||
async deleteUpload(name) {
|
||||
if (!window.confirm(this.$t("tools.repository_server.delete_confirm", { name }))) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const enc = encodeURIComponent(name);
|
||||
await window.api.delete(`/api/v1/repository-server/upload/${enc}`);
|
||||
ToastUtils.success(this.$t("tools.repository_server.delete_ok"));
|
||||
await this.loadAll();
|
||||
} catch (e) {
|
||||
ToastUtils.error(this.$t("tools.repository_server.delete_failed"));
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -195,8 +195,8 @@ export default {
|
||||
route: { name: "propagation-nodes" },
|
||||
icon: "mailbox",
|
||||
iconBg: "tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200",
|
||||
title: "Propagation Nodes",
|
||||
description: "Manage preferred and local propagation nodes with live stats and path checks.",
|
||||
titleKey: "tools.propagation_nodes.title",
|
||||
descriptionKey: "tools.propagation_nodes.description",
|
||||
},
|
||||
{
|
||||
name: "forwarder",
|
||||
@@ -222,6 +222,14 @@ export default {
|
||||
titleKey: "docs.title",
|
||||
descriptionKey: "docs.subtitle",
|
||||
},
|
||||
{
|
||||
name: "repository-server",
|
||||
route: { name: "repository-server" },
|
||||
icon: "package-variant",
|
||||
iconBg: "tool-card__icon bg-sky-50 text-sky-600 dark:bg-sky-900/30 dark:text-sky-200",
|
||||
titleKey: "tools.repository_server.title",
|
||||
descriptionKey: "tools.repository_server.description",
|
||||
},
|
||||
{
|
||||
name: "micron-editor",
|
||||
route: { name: "micron-editor" },
|
||||
|
||||
Reference in New Issue
Block a user