Skip to content

Commit

Permalink
feat: begin UI integration with backend
Browse files Browse the repository at this point in the history
  • Loading branch information
garethgeorge committed Nov 11, 2023
1 parent 5d00e60 commit cccdd29
Show file tree
Hide file tree
Showing 9 changed files with 186 additions and 38 deletions.
18 changes: 18 additions & 0 deletions DESIGN-NOTES.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 27 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
Expand All @@ -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))
Expand Down
1 change: 1 addition & 0 deletions internal/eventlog/maybe-unused.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# unclear if this implementation will be used
27 changes: 27 additions & 0 deletions webui/src/components/Alerts.tsx
Original file line number Diff line number Diff line change
@@ -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);
};
18 changes: 17 additions & 1 deletion webui/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
17 changes: 17 additions & 0 deletions webui/src/state/config.ts
Original file line number Diff line number Diff line change
@@ -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/",
}
);
};
Empty file.
Empty file.
115 changes: 79 additions & 36 deletions webui/src/views/App.tsx
Original file line number Diff line number Diff line change
@@ -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" }}>
Expand All @@ -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={{
Expand All @@ -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,
},
];
};

0 comments on commit cccdd29

Please sign in to comment.