mirror of
https://github.com/garethgeorge/backrest.git
synced 2026-09-15 12:35:37 +00:00
feat: begin UI integration with backend
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
# Datastructures
|
||||
|
||||
- config
|
||||
- user provided configuration, is potentially updated either on startup or by set config rpc
|
||||
- configures
|
||||
- repos - a list of restic repos to which data may be backed up
|
||||
- plans - a list of backup plans which consist of
|
||||
- directories
|
||||
- schedule
|
||||
- retention policy
|
||||
- cache
|
||||
- the cache is a local cache of the restic repo's properties e.g. output from listing snapshots, etc. This may be held in ram or on disk? TBD: decide.
|
||||
- state
|
||||
- state is tracked plan-by-plan and is persisted to disk
|
||||
- stores recent operations done for a plan e.g. last backup, last prune, last check, etc.
|
||||
- stores status and errors for each plan
|
||||
- history is fixed size and is flushed to disk periodically (e.g. every 60 seconds).
|
||||
- the state of a repo is the merge of the states of the plans that reference it.
|
||||
+27
-1
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
v1 "github.com/garethgeorge/resticui/gen/go/v1"
|
||||
"github.com/garethgeorge/resticui/internal/config"
|
||||
"github.com/garethgeorge/resticui/pkg/restic"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
)
|
||||
@@ -49,7 +50,7 @@ func (s *Server) GetConfig(ctx context.Context, empty *emptypb.Empty) (*v1.Confi
|
||||
return config.Default.Get()
|
||||
}
|
||||
|
||||
// SetConfig implements PUT /v1/config
|
||||
// SetConfig implements POST /v1/config
|
||||
func (s *Server) SetConfig(ctx context.Context, c *v1.Config) (*v1.Config, error) {
|
||||
err := config.Default.Update(c)
|
||||
if err != nil {
|
||||
@@ -58,6 +59,29 @@ func (s *Server) SetConfig(ctx context.Context, c *v1.Config) (*v1.Config, error
|
||||
return config.Default.Get()
|
||||
}
|
||||
|
||||
// AddRepo implements POST /v1/config/repo, it includes validation that the repo can be initialized.
|
||||
func (s *Server) AddRepo(ctx context.Context, repo *v1.Repo) (*v1.Config, error) {
|
||||
c, err := config.Default.Get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get config: %w", err)
|
||||
}
|
||||
|
||||
r := restic.NewRepo(repo)
|
||||
// use background context such that the init op can try to complete even if the connection is closed.
|
||||
if err := r.Init(context.Background()); err != nil {
|
||||
return nil, fmt.Errorf("failed to init repo: %w", err)
|
||||
}
|
||||
|
||||
c.Repos = append(c.Repos, repo)
|
||||
|
||||
if err := config.Default.Update(c); err != nil {
|
||||
return nil, fmt.Errorf("failed to update config: %w", err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
|
||||
// GetEvents implements GET /v1/events
|
||||
func (s *Server) GetEvents(_ *emptypb.Empty, stream v1.ResticUI_GetEventsServer) error {
|
||||
reqId := s.reqId.Add(1)
|
||||
@@ -79,6 +103,8 @@ func (s *Server) GetEvents(_ *emptypb.Empty, stream v1.ResticUI_GetEventsServer)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// PublishEvent publishes an event to all GetEvents streams. It is effectively a multicast.
|
||||
func (s *Server) PublishEvent(event *v1.Event) {
|
||||
zap.S().Debug("Publishing event", zap.Any("event", event))
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# unclear if this implementation will be used
|
||||
@@ -0,0 +1,27 @@
|
||||
import React, { useContext } from "react";
|
||||
|
||||
import { message } from "antd";
|
||||
import { MessageInstance } from "antd/es/message/interface";
|
||||
|
||||
const MessageContext = React.createContext<MessageInstance | null>(null);
|
||||
|
||||
export const AlertContextProvider = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const [messageApi, contextHolder] = message.useMessage();
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<MessageContext.Provider value={messageApi}>
|
||||
{children}
|
||||
</MessageContext.Provider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAlertApi = () => {
|
||||
return useContext(MessageContext);
|
||||
};
|
||||
+17
-1
@@ -1,6 +1,22 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./views/App";
|
||||
import { RecoilRoot } from "recoil";
|
||||
import ErrorBoundary from "antd/es/alert/ErrorBoundary";
|
||||
import { AlertContextProvider } from "./components/Alerts";
|
||||
|
||||
const Root = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<RecoilRoot>
|
||||
<AlertContextProvider>{children}</AlertContextProvider>
|
||||
</RecoilRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const el = document.querySelector("#app");
|
||||
el && createRoot(el).render(<App />);
|
||||
el &&
|
||||
createRoot(el).render(
|
||||
<Root>
|
||||
<App />
|
||||
</Root>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { atom, useSetRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { ResticUI } from "../../gen/ts/v1/service.pb";
|
||||
|
||||
export const configState = atom({
|
||||
key: "config",
|
||||
default: null as Config | null,
|
||||
});
|
||||
|
||||
export const fetchConfig = async (): Promise<Config> => {
|
||||
return await ResticUI.GetConfig(
|
||||
{},
|
||||
{
|
||||
pathPrefix: "/api/",
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
+79
-36
@@ -1,46 +1,39 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import {
|
||||
LaptopOutlined,
|
||||
NotificationOutlined,
|
||||
UserOutlined,
|
||||
ScheduleOutlined,
|
||||
DatabaseOutlined,
|
||||
PlusOutlined,
|
||||
CheckCircleOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { Breadcrumb, Layout, Menu, theme } from "antd";
|
||||
import { Breadcrumb, Layout, Menu, Spin, message, theme } from "antd";
|
||||
import { configState, fetchConfig } from "../state/config";
|
||||
import { useRecoilState } from "recoil";
|
||||
import { Config } from "../../gen/ts/v1/config.pb";
|
||||
import { AlertContextProvider, useAlertApi } from "../components/Alerts";
|
||||
|
||||
const { Header, Content, Sider } = Layout;
|
||||
|
||||
const items1: MenuProps["items"] = ["1", "2", "3"].map((key) => ({
|
||||
key,
|
||||
label: `nav ${key}`,
|
||||
}));
|
||||
|
||||
const items2: MenuProps["items"] = [
|
||||
UserOutlined,
|
||||
LaptopOutlined,
|
||||
NotificationOutlined,
|
||||
].map((icon, index) => {
|
||||
const key = String(index + 1);
|
||||
|
||||
return {
|
||||
key: `sub${key}`,
|
||||
icon: React.createElement(icon),
|
||||
label: `subnav ${key}`,
|
||||
|
||||
children: new Array(4).fill(null).map((_, j) => {
|
||||
const subKey = index * 4 + j + 1;
|
||||
return {
|
||||
key: subKey,
|
||||
label: `option${subKey}`,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const App: React.FC = () => {
|
||||
const {
|
||||
token: { colorBgContainer, colorTextLightSolid },
|
||||
} = theme.useToken();
|
||||
|
||||
const [config, setConfig] = useRecoilState(configState);
|
||||
const alertApi = useAlertApi()!;
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
.then((config) => {
|
||||
setConfig(config);
|
||||
})
|
||||
.catch((err) => {
|
||||
alertApi.error(err.message, 60);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const items = getSidenavItems(config);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<Header style={{ display: "flex", alignItems: "center" }}>
|
||||
@@ -51,16 +44,14 @@ export const App: React.FC = () => {
|
||||
<Menu
|
||||
mode="inline"
|
||||
defaultSelectedKeys={["1"]}
|
||||
defaultOpenKeys={["sub1"]}
|
||||
defaultOpenKeys={["plans", "repos"]}
|
||||
style={{ height: "100%", borderRight: 0 }}
|
||||
items={items2}
|
||||
items={items}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout style={{ padding: "0 24px 24px" }}>
|
||||
<Breadcrumb style={{ margin: "16px 0" }}>
|
||||
<Breadcrumb.Item>Home</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>List</Breadcrumb.Item>
|
||||
<Breadcrumb.Item>App</Breadcrumb.Item>
|
||||
</Breadcrumb>
|
||||
<Content
|
||||
style={{
|
||||
@@ -77,3 +68,55 @@ export const App: React.FC = () => {
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
const getSidenavItems = (config: Config | null): MenuProps["items"] => {
|
||||
if (!config) return [];
|
||||
|
||||
const configPlans = config.plans || [];
|
||||
const configRepos = config.repos || [];
|
||||
|
||||
const plans: MenuProps["items"] = [
|
||||
{
|
||||
key: "add-plan",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Plan",
|
||||
},
|
||||
...configPlans.map((plan) => {
|
||||
return {
|
||||
key: "p-" + plan.id,
|
||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||
label: plan.id,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
const repos: MenuProps["items"] = [
|
||||
{
|
||||
key: "add-repo",
|
||||
icon: <PlusOutlined />,
|
||||
label: "Add Repo",
|
||||
},
|
||||
...configRepos.map((repo) => {
|
||||
return {
|
||||
key: "r-" + repo.id,
|
||||
icon: <CheckCircleOutlined style={{ color: "green" }} />,
|
||||
label: repo.id,
|
||||
};
|
||||
}),
|
||||
];
|
||||
|
||||
return [
|
||||
{
|
||||
key: "plans",
|
||||
icon: React.createElement(ScheduleOutlined),
|
||||
label: "Plans",
|
||||
children: plans,
|
||||
},
|
||||
{
|
||||
key: "repos",
|
||||
icon: React.createElement(DatabaseOutlined),
|
||||
label: "Repositories",
|
||||
children: repos,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user