Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ build-cli: build-frontend
CGO_ENABLED=1 $(GO) build \
-trimpath \
-tags=cli \
-ldflags="-s -w -X 'main.Version=$$VERSION' -X 'main.GitCommit=$$COMMIT' -X 'main.Timestamp=$$TIMESTAMP'" \
-ldflags="-s -w -X 'github.com/javi11/altmount/internal/version.Version=$$VERSION' -X 'github.com/javi11/altmount/internal/version.GitCommit=$$COMMIT' -X 'github.com/javi11/altmount/internal/version.Timestamp=$$TIMESTAMP'" \
-o altmount \
./cmd/altmount/main.go

Expand Down
11 changes: 9 additions & 2 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ COPY ./frontend/embed_docker.go ./frontend/embed_docker.go
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist

# Build web binary with enhanced cache mount for native architecture
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIMESTAMP=unknown
RUN --mount=type=cache,target=/root/.cache/go-build,sharing=locked \
--mount=type=cache,target=/tmp/go-build-cache,sharing=locked \
CGO_ENABLED=1 GOOS=linux \
go build -a -ldflags '-linkmode external -extldflags "-static"' -o altmount cmd/altmount/main.go
go build -a \
-ldflags "-linkmode external -extldflags \"-static\" -X 'github.com/javi11/altmount/internal/version.Version=${VERSION}' -X 'github.com/javi11/altmount/internal/version.GitCommit=${COMMIT}' -X 'github.com/javi11/altmount/internal/version.Timestamp=${BUILD_TIMESTAMP}'" \
-o altmount cmd/altmount/main.go

# Final stage - use custom base image with pre-installed packages
ARG TARGETARCH
Expand All @@ -89,10 +94,12 @@ COPY --from=backend-builder /app/altmount /app/altmount
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist

# Install required packages for runtime (mime-support already in custom-base)
# docker.io provides docker CLI for the auto-update feature (requires /var/run/docker.sock mount)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
wget \
ca-certificates && \
ca-certificates \
docker.io && \
rm -rf /var/lib/apt/lists/*

# Install rclone and FUSE support for internal mount functionality
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import type {
ProviderTestResponse,
ProviderUpdateRequest,
} from "../types/config";
import type { UpdateChannel, UpdateStatusResponse } from "../types/update";

export class APIError extends Error {
public status: number;
Expand Down Expand Up @@ -886,6 +887,11 @@ export class APIClient {
body: JSON.stringify({}),
});
}

// Update endpoints
async checkUpdateStatus(channel: UpdateChannel): Promise<UpdateStatusResponse> {
return this.request<UpdateStatusResponse>(`/system/update/status?channel=${channel}`);
}
}

// Export a default instance
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/components/config/SystemConfigSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useToast } from "../../contexts/ToastContext";
import { useRegenerateAPIKey } from "../../hooks/useAuth";
import type { ConfigResponse, LogFormData } from "../../types/config";
import { LoadingSpinner } from "../ui/LoadingSpinner";
import { UpdateSection } from "./UpdateSection";

const LIGHT_THEMES = [
"retro",
Expand Down Expand Up @@ -230,6 +231,9 @@ export function SystemConfigSection({
</div>

<div className="space-y-8">
{/* Updates */}
<UpdateSection />

{/* Appearance */}
<div className="space-y-6 rounded-2xl border-2 border-base-300/80 bg-base-200/60 p-6">
<div className="flex items-center gap-2">
Expand Down
211 changes: 211 additions & 0 deletions frontend/src/components/config/UpdateSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
import {
AlertTriangle,
ArrowUpCircle,
CheckCircle,
ExternalLink,
RefreshCw,
Zap,
} from "lucide-react";
import { useState } from "react";
import { useConfirm } from "../../contexts/ModalContext";
import { useToast } from "../../contexts/ToastContext";
import { useApplyUpdate, useUpdateStatus } from "../../hooks/useUpdate";
import type { UpdateChannel } from "../../types/update";
import { LoadingSpinner } from "../ui/LoadingSpinner";

export function UpdateSection() {
const [channel, setChannel] = useState<UpdateChannel>("latest");
const [checkEnabled, setCheckEnabled] = useState(false);

const { confirmAction } = useConfirm();
const { showToast } = useToast();

const {
data: updateStatus,
isLoading: isChecking,
refetch,
} = useUpdateStatus(channel, checkEnabled);

const applyUpdate = useApplyUpdate();

const handleCheckForUpdates = () => {
setCheckEnabled(true);
refetch();
};

const handleApplyUpdate = async () => {
const confirmed = await confirmAction(
"Apply Update",
`This will pull the latest ${channel} image and restart the container. The service will be briefly unavailable. Continue?`,
{ type: "warning", confirmText: "Update Now", confirmButtonClass: "btn-warning" },
);
if (!confirmed) return;

try {
await applyUpdate.mutateAsync(channel);
showToast({
type: "success",
title: "Update started",
message: "Pulling new image. The container will restart automatically.",
});
} catch (err) {
showToast({
type: "error",
title: "Update failed",
message: err instanceof Error ? err.message : "Failed to apply update",
});
}
};

const dockerUnavailable = updateStatus && !updateStatus.docker_available;
const updateAvailable = updateStatus?.update_available ?? false;

return (
<div className="space-y-6 rounded-2xl border-2 border-base-300/80 bg-base-200/60 p-6">
<div className="flex items-center gap-2">
<ArrowUpCircle className="h-4 w-4 text-base-content/60" />
<h4 className="font-bold text-base-content/40 text-xs uppercase tracking-widest">
Updates
</h4>
<div className="h-px flex-1 bg-base-300/50" />
</div>

{/* Version info */}
{updateStatus && (
<div className="flex flex-wrap gap-3">
<div className="rounded-lg border border-base-300 bg-base-100 px-3 py-2">
<span className="text-[10px] text-base-content/50 uppercase tracking-wider">
Current
</span>
<p className="font-mono font-semibold text-sm">{updateStatus.current_version}</p>
</div>
{updateStatus.git_commit && updateStatus.git_commit !== "unknown" && (
<div className="rounded-lg border border-base-300 bg-base-100 px-3 py-2">
<span className="text-[10px] text-base-content/50 uppercase tracking-wider">
Commit
</span>
<p className="font-mono text-sm">{updateStatus.git_commit}</p>
</div>
)}
{updateStatus.latest_version && (
<div className="rounded-lg border border-base-300 bg-base-100 px-3 py-2">
<span className="text-[10px] text-base-content/50 uppercase tracking-wider">
Latest
</span>
<p className="font-mono font-semibold text-sm">{updateStatus.latest_version}</p>
</div>
)}
</div>
)}

{/* Channel selector */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<fieldset className="fieldset">
<legend className="fieldset-legend font-semibold text-xs">Update Channel</legend>
<div className="join">
<button
type="button"
className={`btn btn-sm join-item ${channel === "latest" ? "btn-primary" : "btn-ghost border-base-300"}`}
onClick={() => {
setChannel("latest");
setCheckEnabled(false);
}}
>
<CheckCircle className="h-3 w-3" />
Latest (stable)
</button>
<button
type="button"
className={`btn btn-sm join-item ${channel === "dev" ? "btn-primary" : "btn-ghost border-base-300"}`}
onClick={() => {
setChannel("dev");
setCheckEnabled(false);
}}
>
<Zap className="h-3 w-3" />
Dev (rolling)
</button>
</div>
<p className="label mt-1 text-[11px] text-base-content/50">
{channel === "latest"
? "Stable releases tagged as vX.Y.Z"
: "Rolling builds from the main branch — may be unstable"}
</p>
</fieldset>

<div className="flex gap-2 self-start sm:self-auto">
<button
type="button"
className="btn btn-sm btn-ghost border-base-300 bg-base-100 hover:bg-base-200"
onClick={handleCheckForUpdates}
disabled={isChecking}
>
{isChecking ? <LoadingSpinner size="sm" /> : <RefreshCw className="h-3 w-3" />}
Check for Updates
</button>

{updateAvailable && (
<button
type="button"
className="btn btn-sm btn-warning"
onClick={handleApplyUpdate}
disabled={applyUpdate.isPending || dockerUnavailable}
>
{applyUpdate.isPending ? (
<LoadingSpinner size="sm" />
) : (
<ArrowUpCircle className="h-3 w-3" />
)}
Update Now
</button>
)}
</div>
</div>

{/* Status messages */}
{updateStatus && !isChecking && (
<>
{updateAvailable ? (
<div className="alert alert-warning">
<ArrowUpCircle className="h-5 w-5 shrink-0" />
<div>
<div className="font-semibold">Update available</div>
<div className="text-sm">
{updateStatus.latest_version} is ready to install.{" "}
{updateStatus.release_url && (
<a
href={updateStatus.release_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 underline"
>
Release notes <ExternalLink className="h-3 w-3" />
</a>
)}
</div>
</div>
</div>
) : updateStatus.latest_version ? (
<div className="alert alert-success">
<CheckCircle className="h-5 w-5 shrink-0" />
<div className="text-sm">You are running the latest version.</div>
</div>
) : null}

{dockerUnavailable && (
<div className="alert alert-warning">
<AlertTriangle className="h-5 w-5 shrink-0" />
<div>
<div className="font-semibold">Auto-update unavailable</div>
<div className="text-sm">
Mount <code className="font-mono">/var/run/docker.sock</code> into the container
to enable one-click updates.
</div>
</div>
</div>
)}
</>
)}
</div>
);
}
8 changes: 6 additions & 2 deletions frontend/src/components/files/FileExplorer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export function FileExplorer({
refetch: refetchDirectory,
} = useWebDAVDirectory(currentPath, isConnected, hasConnectionFailed, showCorrupted);

const { data: history, isLoading: isHistoryLoading, refetch: refetchHistory } = useImportHistory(50);
const {
data: history,
isLoading: isHistoryLoading,
refetch: refetchHistory,
} = useImportHistory(50);

const isLoading = isRecentView ? isHistoryLoading : isDirectoryLoading;
const error = isRecentView ? null : directoryError;
Expand Down Expand Up @@ -97,7 +101,7 @@ export function FileExplorer({

// Filter files based on search term
const filteredFiles = useMemo(() => {
const files = isRecentView ? historyFiles : (directory?.files || []);
const files = isRecentView ? historyFiles : directory?.files || [];
if (!searchTerm.trim()) {
return files;
}
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/hooks/useUpdate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../api/client";
import type { UpdateChannel } from "../types/update";

export function useUpdateStatus(channel: UpdateChannel, enabled: boolean) {
return useQuery({
queryKey: ["system", "update", "status", channel],
queryFn: () => apiClient.checkUpdateStatus(channel),
enabled,
staleTime: 60 * 1000, // Cache for 1 minute
});
}
10 changes: 10 additions & 0 deletions frontend/src/types/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type UpdateChannel = "latest" | "dev";

export interface UpdateStatusResponse {
current_version: string;
git_commit?: string;
channel: UpdateChannel;
latest_version?: string;
update_available: boolean;
release_url?: string;
}
6 changes: 6 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/javi11/altmount/internal/pool"
"github.com/javi11/altmount/internal/progress"
"github.com/javi11/altmount/internal/rclone"
"github.com/javi11/altmount/internal/version"
"github.com/javi11/altmount/pkg/rclonecli"
)

Expand Down Expand Up @@ -241,6 +242,9 @@ func (s *Server) SetupRoutes(app *fiber.App) {
api.Post("/system/cleanup", s.handleSystemCleanup)
api.Post("/system/restart", s.handleSystemRestart)

// Update endpoints
api.Get("/system/update/status", s.handleGetUpdateStatus)

api.Get("/config", s.handleGetConfig)
api.Put("/config", s.handleUpdateConfig)
api.Patch("/config/:section", s.handlePatchConfigSection)
Expand Down Expand Up @@ -341,6 +345,8 @@ func (s *Server) handleGetActiveStreams(c *fiber.Ctx) error {
func (s *Server) getSystemInfo() SystemInfoResponse {
uptime := time.Since(s.startTime)
return SystemInfoResponse{
Version: version.Version,
GitCommit: version.GitCommit,
StartTime: s.startTime,
Uptime: uptime.String(),
GoVersion: runtime.Version(),
Expand Down
21 changes: 21 additions & 0 deletions internal/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -477,11 +477,32 @@ type SystemStatsResponse struct {
// SystemInfoResponse represents system information
type SystemInfoResponse struct {
Version string `json:"version,omitempty"`
GitCommit string `json:"git_commit,omitempty"`
StartTime time.Time `json:"start_time"`
Uptime string `json:"uptime"`
GoVersion string `json:"go_version,omitempty"`
}

// Update API Types

// UpdateChannel represents the Docker image channel for updates.
type UpdateChannel string

const (
UpdateChannelLatest UpdateChannel = "latest"
UpdateChannelDev UpdateChannel = "dev"
)

// UpdateStatusResponse represents the current update status.
type UpdateStatusResponse struct {
CurrentVersion string `json:"current_version"`
GitCommit string `json:"git_commit,omitempty"`
Channel UpdateChannel `json:"channel"`
LatestVersion string `json:"latest_version,omitempty"`
UpdateAvailable bool `json:"update_available"`
ReleaseURL string `json:"release_url,omitempty"`
}

// SystemHealthResponse represents system health check result
type SystemHealthResponse struct {
Status string `json:"status"` // "healthy", "degraded", "unhealthy"
Expand Down
Loading
Loading