feat(repository-server): add repository server navigation and enhance conversation message entry with map link support

This commit is contained in:
Ivan
2026-04-23 02:27:41 -05:00
parent 02eda43304
commit de74a738b4
5 changed files with 610 additions and 163 deletions
@@ -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" },