From cccdd297c15cd47268b2a1903e9624bdbca3dc68 Mon Sep 17 00:00:00 2001 From: garethgeorge Date: Sat, 11 Nov 2023 15:13:56 -0800 Subject: [PATCH] feat: begin UI integration with backend --- DESIGN-NOTES.md | 18 +++++ internal/api/server.go | 28 ++++++- internal/eventlog/maybe-unused.txt | 1 + webui/src/components/Alerts.tsx | 27 +++++++ webui/src/index.tsx | 18 ++++- webui/src/state/config.ts | 17 +++++ webui/src/views/AddPlanModel.tsx | 0 webui/src/views/AddRepoModel.tsx | 0 webui/src/views/App.tsx | 115 ++++++++++++++++++++--------- 9 files changed, 186 insertions(+), 38 deletions(-) create mode 100644 DESIGN-NOTES.md create mode 100644 internal/eventlog/maybe-unused.txt create mode 100644 webui/src/components/Alerts.tsx create mode 100644 webui/src/views/AddPlanModel.tsx create mode 100644 webui/src/views/AddRepoModel.tsx diff --git a/DESIGN-NOTES.md b/DESIGN-NOTES.md new file mode 100644 index 00000000..568cec79 --- /dev/null +++ b/DESIGN-NOTES.md @@ -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. \ No newline at end of file diff --git a/internal/api/server.go b/internal/api/server.go index a3551dc4..36175334 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -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)) diff --git a/internal/eventlog/maybe-unused.txt b/internal/eventlog/maybe-unused.txt new file mode 100644 index 00000000..250331e0 --- /dev/null +++ b/internal/eventlog/maybe-unused.txt @@ -0,0 +1 @@ +# unclear if this implementation will be used \ No newline at end of file diff --git a/webui/src/components/Alerts.tsx b/webui/src/components/Alerts.tsx new file mode 100644 index 00000000..2ed8fc50 --- /dev/null +++ b/webui/src/components/Alerts.tsx @@ -0,0 +1,27 @@ +import React, { useContext } from "react"; + +import { message } from "antd"; +import { MessageInstance } from "antd/es/message/interface"; + +const MessageContext = React.createContext(null); + +export const AlertContextProvider = ({ + children, +}: { + children: React.ReactNode; +}) => { + const [messageApi, contextHolder] = message.useMessage(); + + return ( + <> + {contextHolder} + + {children} + + + ); +}; + +export const useAlertApi = () => { + return useContext(MessageContext); +}; diff --git a/webui/src/index.tsx b/webui/src/index.tsx index 2dccca11..e3c0db13 100644 --- a/webui/src/index.tsx +++ b/webui/src/index.tsx @@ -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 ( + + {children} + + ); +}; const el = document.querySelector("#app"); -el && createRoot(el).render(); +el && + createRoot(el).render( + + + + ); diff --git a/webui/src/state/config.ts b/webui/src/state/config.ts index e69de29b..8e6eba4f 100644 --- a/webui/src/state/config.ts +++ b/webui/src/state/config.ts @@ -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 => { + return await ResticUI.GetConfig( + {}, + { + pathPrefix: "/api/", + } + ); +}; diff --git a/webui/src/views/AddPlanModel.tsx b/webui/src/views/AddPlanModel.tsx new file mode 100644 index 00000000..e69de29b diff --git a/webui/src/views/AddRepoModel.tsx b/webui/src/views/AddRepoModel.tsx new file mode 100644 index 00000000..e69de29b diff --git a/webui/src/views/App.tsx b/webui/src/views/App.tsx index 08fcdb43..f146660d 100644 --- a/webui/src/views/App.tsx +++ b/webui/src/views/App.tsx @@ -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 (
@@ -51,16 +44,14 @@ export const App: React.FC = () => { Home - List - App { ); }; + +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: , + label: "Add Plan", + }, + ...configPlans.map((plan) => { + return { + key: "p-" + plan.id, + icon: , + label: plan.id, + }; + }), + ]; + + const repos: MenuProps["items"] = [ + { + key: "add-repo", + icon: , + label: "Add Repo", + }, + ...configRepos.map((repo) => { + return { + key: "r-" + repo.id, + icon: , + label: repo.id, + }; + }), + ]; + + return [ + { + key: "plans", + icon: React.createElement(ScheduleOutlined), + label: "Plans", + children: plans, + }, + { + key: "repos", + icon: React.createElement(DatabaseOutlined), + label: "Repositories", + children: repos, + }, + ]; +};