Fix updater latest.json and add log viewer in settings
Some checks failed
Release / build-and-release (push) Has been cancelled
Some checks failed
Release / build-and-release (push) Has been cancelled
Fix updater: strip v-prefix from version in latest.json, and delete old package before re-uploading to avoid 409 conflicts. Add frontend log capture (console intercept) with a log viewer card in the settings page (filterable, copyable, clearable). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7d7be4f591
commit
d604d5ae63
7 changed files with 226 additions and 16 deletions
|
|
@ -85,6 +85,7 @@ jobs:
|
||||||
- name: Generate latest.json
|
- name: Generate latest.json
|
||||||
run: |
|
run: |
|
||||||
TAG="${GITHUB_REF_NAME}"
|
TAG="${GITHUB_REF_NAME}"
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
PUB_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||||
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
BASE_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/releases/download/${TAG}"
|
||||||
|
|
||||||
|
|
@ -121,7 +122,7 @@ jobs:
|
||||||
fi
|
fi
|
||||||
|
|
||||||
jq -n \
|
jq -n \
|
||||||
--arg version "$TAG" \
|
--arg version "$VERSION" \
|
||||||
--arg notes "${{ steps.changelog.outputs.notes }}" \
|
--arg notes "${{ steps.changelog.outputs.notes }}" \
|
||||||
--arg pub_date "$PUB_DATE" \
|
--arg pub_date "$PUB_DATE" \
|
||||||
--argjson platforms "$PLATFORMS" \
|
--argjson platforms "$PLATFORMS" \
|
||||||
|
|
@ -198,6 +199,16 @@ jobs:
|
||||||
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
API_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
API_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||||
|
# Always delete the old version first to avoid 409 conflicts
|
||||||
|
echo "Deleting old package version (if any)..."
|
||||||
|
curl -s -X DELETE \
|
||||||
|
"${API_URL}/latest.json" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" || true
|
||||||
|
# Delete the package version itself to allow re-upload
|
||||||
|
DELETE_URL="${GITHUB_SERVER_URL}/api/packages/${GITHUB_REPOSITORY_OWNER}/generic/simpl-resultat/latest"
|
||||||
|
curl -s -X DELETE \
|
||||||
|
"${DELETE_URL}" \
|
||||||
|
-H "Authorization: token ${FORGEJO_TOKEN}" || true
|
||||||
echo "Uploading latest.json to package registry..."
|
echo "Uploading latest.json to package registry..."
|
||||||
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
|
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
|
||||||
"${API_URL}/latest.json" \
|
"${API_URL}/latest.json" \
|
||||||
|
|
@ -206,20 +217,7 @@ jobs:
|
||||||
--data-binary "@release-assets/latest.json" \
|
--data-binary "@release-assets/latest.json" \
|
||||||
-o /tmp/pkg_response.json)
|
-o /tmp/pkg_response.json)
|
||||||
echo "HTTP $HTTP_CODE"
|
echo "HTTP $HTTP_CODE"
|
||||||
if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "409" ]; then
|
if [ "$HTTP_CODE" != "201" ]; then
|
||||||
echo "Upload failed:"
|
echo "Upload response:"
|
||||||
cat /tmp/pkg_response.json
|
cat /tmp/pkg_response.json
|
||||||
fi
|
fi
|
||||||
if [ "$HTTP_CODE" = "409" ]; then
|
|
||||||
echo "Package version exists, deleting and re-uploading..."
|
|
||||||
curl -s -X DELETE \
|
|
||||||
"${API_URL}/latest.json" \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}"
|
|
||||||
HTTP_CODE=$(curl -w "%{http_code}" -X PUT \
|
|
||||||
"${API_URL}/latest.json" \
|
|
||||||
-H "Authorization: token ${FORGEJO_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
--data-binary "@release-assets/latest.json" \
|
|
||||||
-o /tmp/pkg_response.json)
|
|
||||||
echo "Re-upload HTTP $HTTP_CODE"
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
116
src/components/settings/LogViewerCard.tsx
Normal file
116
src/components/settings/LogViewerCard.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ScrollText, Trash2, Copy, Check } from "lucide-react";
|
||||||
|
import { getLogs, clearLogs, subscribe, type LogLevel } from "../../services/logService";
|
||||||
|
|
||||||
|
type Filter = "all" | LogLevel;
|
||||||
|
|
||||||
|
export default function LogViewerCard() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const logs = useSyncExternalStore(subscribe, getLogs, getLogs);
|
||||||
|
|
||||||
|
const filtered = filter === "all" ? logs : logs.filter((l) => l.level === filter);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (listRef.current) {
|
||||||
|
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [filtered.length]);
|
||||||
|
|
||||||
|
const handleCopy = async () => {
|
||||||
|
const text = filtered
|
||||||
|
.map((l) => {
|
||||||
|
const time = new Date(l.timestamp).toLocaleTimeString();
|
||||||
|
return `[${time}] [${l.level.toUpperCase()}] ${l.message}`;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelColor: Record<LogLevel, string> = {
|
||||||
|
info: "text-[var(--muted-foreground)]",
|
||||||
|
warn: "text-amber-500",
|
||||||
|
error: "text-[var(--negative)]",
|
||||||
|
};
|
||||||
|
|
||||||
|
const filters: { value: Filter; label: string }[] = [
|
||||||
|
{ value: "all", label: t("settings.logs.filterAll") },
|
||||||
|
{ value: "error", label: "Error" },
|
||||||
|
{ value: "warn", label: "Warn" },
|
||||||
|
{ value: "info", label: "Info" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||||
|
<ScrollText size={18} />
|
||||||
|
{t("settings.logs.title")}
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={filtered.length === 0}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
|
{copied ? t("settings.logs.copied") : t("settings.logs.copy")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={clearLogs}
|
||||||
|
disabled={logs.length === 0}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm border border-[var(--border)] rounded-lg hover:bg-[var(--border)] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t("settings.logs.clear")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{filters.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setFilter(f.value)}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
filter === f.value
|
||||||
|
? "bg-[var(--primary)] text-[var(--primary-foreground)]"
|
||||||
|
: "bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={listRef}
|
||||||
|
className="h-64 overflow-y-auto rounded-lg bg-[var(--background)] border border-[var(--border)] p-3 font-mono text-xs space-y-0.5"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p className="text-[var(--muted-foreground)] text-center py-8">
|
||||||
|
{t("settings.logs.empty")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filtered.map((entry, i) => (
|
||||||
|
<div key={i} className="flex gap-2">
|
||||||
|
<span className="text-[var(--muted-foreground)] shrink-0">
|
||||||
|
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
<span className={`shrink-0 uppercase font-semibold w-12 ${levelColor[entry.level]}`}>
|
||||||
|
{entry.level}
|
||||||
|
</span>
|
||||||
|
<span className="text-[var(--foreground)] break-all">{entry.message}</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -467,6 +467,14 @@
|
||||||
"title": "User Guide",
|
"title": "User Guide",
|
||||||
"description": "Learn how to use all features of the app"
|
"description": "Learn how to use all features of the app"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Logs",
|
||||||
|
"clear": "Clear",
|
||||||
|
"copy": "Copy",
|
||||||
|
"copied": "Copied!",
|
||||||
|
"empty": "No logs",
|
||||||
|
"filterAll": "All"
|
||||||
|
},
|
||||||
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.",
|
"dataSafeNotice": "Your data is safe — only the app binary is replaced, your database is not modified.",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "About Settings",
|
"title": "About Settings",
|
||||||
|
|
|
||||||
|
|
@ -467,6 +467,14 @@
|
||||||
"title": "Guide d'utilisation",
|
"title": "Guide d'utilisation",
|
||||||
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
"description": "Apprenez à utiliser toutes les fonctionnalités de l'application"
|
||||||
},
|
},
|
||||||
|
"logs": {
|
||||||
|
"title": "Journaux",
|
||||||
|
"clear": "Effacer",
|
||||||
|
"copy": "Copier",
|
||||||
|
"copied": "Copié !",
|
||||||
|
"empty": "Aucun journal",
|
||||||
|
"filterAll": "Tout"
|
||||||
|
},
|
||||||
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.",
|
"dataSafeNotice": "Vos données sont en sécurité — seul le programme est remplacé, votre base de données n'est pas modifiée.",
|
||||||
"help": {
|
"help": {
|
||||||
"title": "À propos des Paramètres",
|
"title": "À propos des Paramètres",
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,12 @@ import ReactDOM from "react-dom/client";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { ProfileProvider } from "./contexts/ProfileContext";
|
import { ProfileProvider } from "./contexts/ProfileContext";
|
||||||
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
import ErrorBoundary from "./components/shared/ErrorBoundary";
|
||||||
|
import { initLogCapture } from "./services/logService";
|
||||||
import "./i18n/config";
|
import "./i18n/config";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
|
initLogCapture();
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ProfileProvider>
|
<ProfileProvider>
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { Link } from "react-router-dom";
|
||||||
import { APP_NAME } from "../shared/constants";
|
import { APP_NAME } from "../shared/constants";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import DataManagementCard from "../components/settings/DataManagementCard";
|
import DataManagementCard from "../components/settings/DataManagementCard";
|
||||||
|
import LogViewerCard from "../components/settings/LogViewerCard";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -221,6 +222,9 @@ export default function SettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Logs */}
|
||||||
|
<LogViewerCard />
|
||||||
|
|
||||||
{/* Data management */}
|
{/* Data management */}
|
||||||
<DataManagementCard />
|
<DataManagementCard />
|
||||||
|
|
||||||
|
|
|
||||||
73
src/services/logService.ts
Normal file
73
src/services/logService.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
export type LogLevel = "info" | "warn" | "error";
|
||||||
|
|
||||||
|
export interface LogEntry {
|
||||||
|
timestamp: number;
|
||||||
|
level: LogLevel;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogListener = () => void;
|
||||||
|
|
||||||
|
const MAX_ENTRIES = 500;
|
||||||
|
const logs: LogEntry[] = [];
|
||||||
|
const listeners = new Set<LogListener>();
|
||||||
|
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
|
function addEntry(level: LogLevel, args: unknown[]) {
|
||||||
|
const message = args
|
||||||
|
.map((a) => {
|
||||||
|
if (typeof a === "string") return a;
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
logs.push({ timestamp: Date.now(), level, message });
|
||||||
|
if (logs.length > MAX_ENTRIES) {
|
||||||
|
logs.splice(0, logs.length - MAX_ENTRIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initLogCapture() {
|
||||||
|
if (initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
const origLog = console.log.bind(console);
|
||||||
|
const origWarn = console.warn.bind(console);
|
||||||
|
const origError = console.error.bind(console);
|
||||||
|
|
||||||
|
console.log = (...args: unknown[]) => {
|
||||||
|
addEntry("info", args);
|
||||||
|
origLog(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.warn = (...args: unknown[]) => {
|
||||||
|
addEntry("warn", args);
|
||||||
|
origWarn(...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
console.error = (...args: unknown[]) => {
|
||||||
|
addEntry("error", args);
|
||||||
|
origError(...args);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLogs(): readonly LogEntry[] {
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLogs() {
|
||||||
|
logs.length = 0;
|
||||||
|
listeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(listener: LogListener): () => void {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => listeners.delete(listener);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue