mirror of
https://github.com/kopia/kopia.git
synced 2026-07-27 13:56:45 -04:00
htmlui: added experimental HTML-based UI
This is enabled by `kopia server --ui` and can be viewed in a browser at http://localhost:51515/ Right now it can only list snapshots and policies (barely).
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -17,3 +17,4 @@ test_service_account.json
|
||||
id_kopia
|
||||
known_hosts
|
||||
node_modules/
|
||||
*_bindata.go
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# This is an example goreleaser.yaml file with some sane defaults.
|
||||
# Make sure to check the documentation at http://goreleaser.com
|
||||
before:
|
||||
hooks:
|
||||
- make html-ui-bindata
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
@@ -11,6 +14,8 @@ builds:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
flags:
|
||||
- -tags=embedhtml
|
||||
ldflags:
|
||||
- -s -w -X "github.com/kopia/kopia/repo.BuildVersion={{.Version}}" -X "github.com/kopia/kopia/repo.BuildInfo={{.Commit}}"
|
||||
archives:
|
||||
|
||||
37
Makefile
37
Makefile
@@ -1,11 +1,7 @@
|
||||
COVERAGE_PACKAGES=./repo/...,./fs/...,./snapshot/...
|
||||
GOLANGCI_LINT_VERSION=v1.18.0
|
||||
LINTER_TOOL=.tools/bin/golangci-lint
|
||||
GOVERALLS_TOOL=.tools/bin/goveralls
|
||||
GO_TEST=go test
|
||||
NODE_VERSION=12.13.0
|
||||
TOOLS_DIR=$(CURDIR)/.tools
|
||||
NPM_TOOL=$(TOOLS_DIR)/nodejs/node/bin/npm
|
||||
|
||||
include tools/tools.mk
|
||||
|
||||
-include ./Makefile.local.mk
|
||||
|
||||
@@ -43,19 +39,20 @@ build-linux-arm64:
|
||||
|
||||
build-all: build-linux-amd64 build-windows-amd64 build-darwin-amd64 build-linux-arm build-linux-arm64
|
||||
|
||||
$(LINTER_TOOL):
|
||||
mkdir -p .tools
|
||||
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b .tools/bin/ $(GOLANGCI_LINT_VERSION)
|
||||
|
||||
$(GOVERALLS_TOOL):
|
||||
mkdir -p .tools
|
||||
GO111MODULE=off GOPATH=$(CURDIR)/.tools go get github.com/mattn/goveralls
|
||||
|
||||
travis-setup: travis-install-gpg-key travis-install-test-credentials
|
||||
go mod download
|
||||
|
||||
website:
|
||||
$(MAKE) -C site
|
||||
$(MAKE) -C site build
|
||||
|
||||
html-ui:
|
||||
$(MAKE) -C htmlui build-html
|
||||
|
||||
html-ui-bindata: html-ui $(BINDATA_TOOL)
|
||||
(cd htmlui/build && $(BINDATA_TOOL) -fs -tags embedhtml -o "$(CURDIR)/internal/server/htmlui_bindata.go" -pkg server -ignore '.map' . static/css static/js static/media)
|
||||
|
||||
html-ui-bindata-fallback: $(BINDATA_TOOL)
|
||||
(cd internal/server && $(BINDATA_TOOL) -fs -tags !embedhtml -o "$(CURDIR)/internal/server/htmlui_fallback.go" -pkg server index.html)
|
||||
|
||||
travis-release: test-with-coverage lint vet verify-release integration-tests upload-coverage website stress-test
|
||||
|
||||
@@ -169,14 +166,4 @@ travis-create-long-term-repository:
|
||||
|
||||
endif
|
||||
|
||||
site/node_modules: install-webtools
|
||||
|
||||
install-webtools:
|
||||
mkdir -p $(TOOLS_DIR)/nodejs
|
||||
|
||||
ifeq ($(uname),Linux)
|
||||
curl -LsS https://nodejs.org/dist/v$(NODE_VERSION)/node-v$(NODE_VERSION)-linux-x64.tar.gz | tar zx -C $(TOOLS_DIR)/nodejs
|
||||
else
|
||||
curl -LsS https://nodejs.org/dist/v$(NODE_VERSION)/node-v$(NODE_VERSION)-darwin-x64.tar.gz | tar zx -C $(TOOLS_DIR)/nodejs
|
||||
endif
|
||||
mv $(TOOLS_DIR)/nodejs/node-v$(NODE_VERSION)* $(TOOLS_DIR)/nodejs/node/
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
serverStartCommand = serverCommands.Command("start", "Start Kopia server").Default()
|
||||
serverStartHTMLPath = serverStartCommand.Flag("html", "Server the provided HTML at the root URL").ExistingDir()
|
||||
serverStartUI = serverStartCommand.Flag("ui", "Start the server with HTML UI (EXPERIMENTAL)").Bool()
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -36,6 +37,8 @@ func runServer(ctx context.Context, rep *repo.Repository) error {
|
||||
if *serverStartHTMLPath != "" {
|
||||
fileServer := http.FileServer(http.Dir(*serverStartHTMLPath))
|
||||
http.Handle("/", fileServer)
|
||||
} else if *serverStartUI {
|
||||
http.Handle("/", http.FileServer(server.AssetFile()))
|
||||
}
|
||||
return http.ListenAndServe(*serverAddress, nil)
|
||||
}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -12,6 +12,7 @@ require (
|
||||
github.com/chmduquesne/rollinghash v4.0.0+incompatible
|
||||
github.com/danieljoos/wincred v1.0.2 // indirect
|
||||
github.com/efarrer/iothrottler v0.0.1
|
||||
github.com/go-bindata/go-bindata v3.1.2+incompatible // indirect
|
||||
github.com/go-ini/ini v1.46.0 // indirect
|
||||
github.com/godbus/dbus v4.1.0+incompatible // indirect
|
||||
github.com/kylelemons/godebug v1.1.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -36,6 +36,8 @@ github.com/efarrer/iothrottler v0.0.0-20141121142253-60e7e547c7fe h1:WAx1vRufH0I
|
||||
github.com/efarrer/iothrottler v0.0.0-20141121142253-60e7e547c7fe/go.mod h1:zjXkUoNEq44qYz/1TlzBhN2W21rDU3HvDBiJWQAZTq8=
|
||||
github.com/efarrer/iothrottler v0.0.1 h1:N5uXoCpk8T1nfl8z7l4YIJUI/2/mL5pQNsOkeMuVnH8=
|
||||
github.com/efarrer/iothrottler v0.0.1/go.mod h1:zGWF5N0NKSCskcPFytDAFwI121DdU/NfW4XOjpTR+ys=
|
||||
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
|
||||
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
|
||||
github.com/go-ini/ini v1.42.0 h1:TWr1wGj35+UiWHlBA8er89seFXxzwFn11spilrrj+38=
|
||||
github.com/go-ini/ini v1.42.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-ini/ini v1.46.0 h1:hDJFfs/9f75875scvqLkhNB5Jz5/DybKEOZ5MLF+ng4=
|
||||
|
||||
2
htmlui/.gitignore
vendored
Normal file
2
htmlui/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
build/
|
||||
12
htmlui/Makefile
Normal file
12
htmlui/Makefile
Normal file
@@ -0,0 +1,12 @@
|
||||
all:
|
||||
|
||||
include ../tools/tools.mk
|
||||
|
||||
dev: node_modules
|
||||
PATH=$(TOOL_PATH) $(NPM_TOOL) run start
|
||||
|
||||
build-html: node_modules
|
||||
PATH=$(TOOL_PATH) $(NPM_TOOL) run build
|
||||
|
||||
node_modules: $(NPM_TOOL)
|
||||
PATH=$(TOOL_PATH) $(NPM_TOOL) install
|
||||
68
htmlui/README.md
Normal file
68
htmlui/README.md
Normal file
@@ -0,0 +1,68 @@
|
||||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br />
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br />
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br />
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br />
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br />
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
||||
1
htmlui/env.sh
Normal file
1
htmlui/env.sh
Normal file
@@ -0,0 +1 @@
|
||||
PATH=$(pwd)/../.tools/nodejs/node/bin:$PATH
|
||||
13816
htmlui/package-lock.json
generated
Normal file
13816
htmlui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
htmlui/package.json
Normal file
37
htmlui/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "htmlui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"axios": "^0.19.0",
|
||||
"bootstrap": "^4.3.1",
|
||||
"react": "^16.11.0",
|
||||
"react-bootstrap": "^1.0.0-beta.14",
|
||||
"react-dom": "^16.11.0",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-scripts": "3.2.0",
|
||||
"react-table": "^6.10.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"proxy": "http://localhost:51515",
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
htmlui/public/favicon.ico
Normal file
BIN
htmlui/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 144 KiB |
43
htmlui/public/index.html
Normal file
43
htmlui/public/index.html
Normal file
@@ -0,0 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app"
|
||||
/>
|
||||
<link rel="apple-touch-icon" href="logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Kopia UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
BIN
htmlui/public/logo192.png
Normal file
BIN
htmlui/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.8 KiB |
BIN
htmlui/public/logo512.png
Normal file
BIN
htmlui/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
25
htmlui/public/manifest.json
Normal file
25
htmlui/public/manifest.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"short_name": "React App",
|
||||
"name": "Create React App Sample",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
},
|
||||
{
|
||||
"src": "logo192.png",
|
||||
"type": "image/png",
|
||||
"sizes": "192x192"
|
||||
},
|
||||
{
|
||||
"src": "logo512.png",
|
||||
"type": "image/png",
|
||||
"sizes": "512x512"
|
||||
}
|
||||
],
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
||||
2
htmlui/public/robots.txt
Normal file
2
htmlui/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
7
htmlui/src/App.css
Normal file
7
htmlui/src/App.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.App-logo {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.active{
|
||||
font-weight: bold;
|
||||
}
|
||||
67
htmlui/src/App.js
Normal file
67
htmlui/src/App.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import logo from './kopia-flat.svg';
|
||||
import './App.css';
|
||||
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'react-table/react-table.css'
|
||||
|
||||
import { SourcesTable } from "./SourcesTable";
|
||||
import { PoliciesTable } from "./PoliciesTable";
|
||||
import { SnapshotsTable } from "./SnapshotsTable";
|
||||
import Navbar from 'react-bootstrap/Navbar';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Nav from 'react-bootstrap/Nav';
|
||||
import Container from 'react-bootstrap/Container';
|
||||
|
||||
import { withRouter } from "react-router";
|
||||
|
||||
import {
|
||||
BrowserRouter as Router,
|
||||
Switch,
|
||||
Route,
|
||||
} from "react-router-dom";
|
||||
|
||||
const SnapshotsTableWithRouter = withRouter(SnapshotsTable);
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<Navbar bg="light" expand="lg">
|
||||
<Navbar.Brand href="/"><img src={logo} className="App-logo" alt="logo" /></Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="basic-navbar-nav" />
|
||||
<Navbar.Collapse id="basic-navbar-nav">
|
||||
<Nav className="mr-auto">
|
||||
<NavLink className="nav-link" activeClassName="active" exact to="/">Status</NavLink>
|
||||
<NavLink className="nav-link" activeClassName="active" to="/snapshots">Snapshots</NavLink>
|
||||
<NavLink className="nav-link" activeClassName="active" to="/policies">Policies</NavLink>
|
||||
</Nav>
|
||||
</Navbar.Collapse>
|
||||
</Navbar>
|
||||
|
||||
<Container>
|
||||
<Switch>
|
||||
<Route path="/snapshots/single-source/">
|
||||
<SnapshotsTableWithRouter />
|
||||
</Route>
|
||||
<Route path="/snapshots/dir/">
|
||||
<p>not implemented: directory browser</p>
|
||||
</Route>
|
||||
<Route path="/snapshots/file/">
|
||||
<p>not implemented: file browser</p>
|
||||
</Route>
|
||||
<Route path="/snapshots">
|
||||
<SourcesTable />
|
||||
</Route>
|
||||
<Route path="/policies">
|
||||
<PoliciesTable />
|
||||
</Route>
|
||||
<Route exact path="/">
|
||||
<p>not implemented: Status</p>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Container>
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
9
htmlui/src/App.test.js
Normal file
9
htmlui/src/App.test.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const div = document.createElement('div');
|
||||
ReactDOM.render(<App />, div);
|
||||
ReactDOM.unmountComponentAtNode(div);
|
||||
});
|
||||
77
htmlui/src/PoliciesTable.js
Normal file
77
htmlui/src/PoliciesTable.js
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { Component } from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
import Button from 'react-bootstrap/Button';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
sourceDisplayName,
|
||||
intervalDisplayName,
|
||||
timesOfDayDisplayName,
|
||||
} from './uiutil';
|
||||
|
||||
export class PoliciesTable extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
items: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
;
|
||||
componentDidMount() {
|
||||
axios.get('/api/v1/policies').then(result => {
|
||||
this.setState({ "items": result.data.policies });
|
||||
}).catch(error => this.setState({
|
||||
error,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
render() {
|
||||
const { items, isLoading, error } = this.state;
|
||||
if (error) {
|
||||
return <p>{error.message}</p>;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <p>Loading ...</p>;
|
||||
}
|
||||
const columns = [{
|
||||
id: 'target',
|
||||
Header: 'Target',
|
||||
accessor: x => sourceDisplayName(x.target),
|
||||
}, {
|
||||
Header: 'Latest',
|
||||
accessor: 'policy.retention.keepLatest'
|
||||
}, {
|
||||
Header: 'Hourly',
|
||||
accessor: 'policy.retention.keepHourly'
|
||||
}, {
|
||||
Header: 'Daily',
|
||||
accessor: 'policy.retention.keepDaily'
|
||||
}, {
|
||||
Header: 'Weekly',
|
||||
accessor: 'policy.retention.keepWeekly'
|
||||
}, {
|
||||
Header: 'Monthly',
|
||||
accessor: 'policy.retention.keepMonthly'
|
||||
}, {
|
||||
Header: 'Annual',
|
||||
accessor: 'policy.retention.keepAnnual'
|
||||
}, {
|
||||
id: 'interval',
|
||||
Header: 'Interval',
|
||||
accessor: x => intervalDisplayName(x.policy.scheduling.interval),
|
||||
}, {
|
||||
id: 'timesOfDay',
|
||||
Header: 'Times of Day',
|
||||
accessor: x => timesOfDayDisplayName(x.policy.scheduling.timesOfDay),
|
||||
}]
|
||||
|
||||
return <div>
|
||||
<Button size="xxl">
|
||||
flat button
|
||||
</Button>
|
||||
<ReactTable data={items} columns={columns} />;
|
||||
</div>
|
||||
}
|
||||
}
|
||||
163
htmlui/src/SnapshotsTable.js
Normal file
163
htmlui/src/SnapshotsTable.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import React, { Component } from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
parseQuery,
|
||||
sizeDisplayName,
|
||||
objectLink,
|
||||
rfc3339TimestampDisplayName,
|
||||
} from './uiutil';
|
||||
|
||||
import {
|
||||
Link
|
||||
} from "react-router-dom";
|
||||
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
import Badge from 'react-bootstrap/Badge';
|
||||
import Form from 'react-bootstrap/Form';
|
||||
|
||||
|
||||
function pillVariant(tag) {
|
||||
if (tag.startsWith("latest-")) {
|
||||
return "success";
|
||||
}
|
||||
if (tag.startsWith("daily-")) {
|
||||
return "info";
|
||||
}
|
||||
if (tag.startsWith("weekly-")) {
|
||||
return "danger";
|
||||
}
|
||||
if (tag.startsWith("monthly-")) {
|
||||
return "dark";
|
||||
}
|
||||
if (tag.startsWith("annual-")) {
|
||||
return "warning";
|
||||
}
|
||||
return "primary";
|
||||
}
|
||||
|
||||
export class SnapshotsTable extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
snapshots: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
componentDidMount() {
|
||||
let q = parseQuery(this.props.location.search);
|
||||
|
||||
this.setState({
|
||||
isLoading: true,
|
||||
host: q.host,
|
||||
userName: q.userName,
|
||||
path: q.path,
|
||||
hiddenCount: 0,
|
||||
selectedSnapshot: null,
|
||||
});
|
||||
const u = '/api/v1/snapshots?host=' + q.host + '&userName=' + q.userName + '&path=' + q.path;
|
||||
console.log('u', u);
|
||||
axios.get(u).then(result => {
|
||||
console.log('got snapshots', result.data);
|
||||
this.setState({
|
||||
snapshots: result.data.snapshots,
|
||||
isLoading: false,
|
||||
});
|
||||
}).catch(error => this.setState({
|
||||
error,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
|
||||
coalesceSnapshots(s) {
|
||||
let filteredSnapshots = [];
|
||||
|
||||
let lastRootID = "";
|
||||
let hiddenCount = 0;
|
||||
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
if (s[i].rootID !== lastRootID) {
|
||||
filteredSnapshots.push(s[i]);
|
||||
} else {
|
||||
hiddenCount++;
|
||||
}
|
||||
lastRootID = s[i].rootID;
|
||||
}
|
||||
return { filteredSnapshots, hiddenCount };
|
||||
}
|
||||
|
||||
selectSnapshot(x) {
|
||||
this.setState({
|
||||
selectedSnapshot: x,
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
let { snapshots, isLoading, error } = this.state;
|
||||
if (error) {
|
||||
return <p>{error.message}</p>;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <Spinner animation="border" variant="primary" />;
|
||||
}
|
||||
|
||||
snapshots.sort((a,b)=> {
|
||||
if (a.startTime < b.startTime) { return 1; };
|
||||
if (a.startTime > b.startTime) { return -1; };
|
||||
return 0;
|
||||
});
|
||||
|
||||
let { filteredSnapshots, hiddenCount } = this.coalesceSnapshots(snapshots);
|
||||
|
||||
const columns = [{
|
||||
Header: 'Start time',
|
||||
accessor: 'startTime',
|
||||
width: 200,
|
||||
Cell: x => rfc3339TimestampDisplayName(x.value),
|
||||
}, {
|
||||
Header: 'Size',
|
||||
accessor: 'summary.size',
|
||||
width: 100,
|
||||
Cell: x => sizeDisplayName(x.value),
|
||||
}, {
|
||||
Header: 'Files',
|
||||
accessor: 'summary.files',
|
||||
width: 100,
|
||||
}, {
|
||||
Header: 'Dirs',
|
||||
accessor: 'summary.dirs',
|
||||
width: 100,
|
||||
}, {
|
||||
id: 'rootID',
|
||||
Header: 'Root',
|
||||
accessor: x => <Link to={objectLink(x.rootID)}>{x.rootID}</Link>,
|
||||
width: 300,
|
||||
}, {
|
||||
Header: 'Retention',
|
||||
accessor: 'retention',
|
||||
Cell: x => <span>{x.value.map(l =>
|
||||
<Badge variant={pillVariant(l)}>{l}</Badge>
|
||||
)}</span>
|
||||
}]
|
||||
|
||||
return <div>
|
||||
<Form>
|
||||
<Form.Group controlId="formBasicCheckbox">
|
||||
<Form.Label>Displaying {filteredSnapshots.length} snapshots of <b>{this.state.userName}@{this.state.host}:{this.state.path}</b></Form.Label>
|
||||
</Form.Group>
|
||||
|
||||
<Form.Group controlId="formBasicCheckbox">
|
||||
<Form.Check type="checkbox" label={'Show ' + hiddenCount + ' hidden snapshots'} />
|
||||
</Form.Group>
|
||||
|
||||
{this.state.selectedSnapshot ?
|
||||
<Form.Group controlId="formSelected">
|
||||
<Form.Label>Selected <pre>{JSON.stringify(this.state.selectedSnapshot, null, 2)}</pre></Form.Label>
|
||||
</Form.Group> : null}
|
||||
</Form>
|
||||
<ReactTable data={filteredSnapshots} columns={columns} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
147
htmlui/src/SourcesTable.js
Normal file
147
htmlui/src/SourcesTable.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { Component } from 'react';
|
||||
import ReactTable from 'react-table';
|
||||
import axios from 'axios';
|
||||
|
||||
import {
|
||||
Link
|
||||
} from 'react-router-dom';
|
||||
|
||||
import Dropdown from 'react-bootstrap/Dropdown';
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
import Row from 'react-bootstrap/Row';
|
||||
|
||||
const allHosts = "(all)"
|
||||
const allUsers = "(all)"
|
||||
|
||||
export class SourcesTable extends Component {
|
||||
constructor() {
|
||||
super();
|
||||
this.state = {
|
||||
sources: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
selectedHost: allHosts,
|
||||
selectedUser: allUsers,
|
||||
};
|
||||
}
|
||||
;
|
||||
componentDidMount() {
|
||||
this.setState({ isLoading: true });
|
||||
axios.get('/api/v1/sources').then(result => {
|
||||
this.setState({
|
||||
sources: result.data.sources,
|
||||
isLoading: false,
|
||||
});
|
||||
}).catch(error => this.setState({
|
||||
error,
|
||||
isLoading: false
|
||||
}));
|
||||
}
|
||||
|
||||
selectHost(h) {
|
||||
this.setState({
|
||||
selectedHost: h,
|
||||
});
|
||||
}
|
||||
|
||||
selectUser(u) {
|
||||
this.setState({
|
||||
selectedUser: u,
|
||||
});
|
||||
}
|
||||
|
||||
hostClicked(h) {
|
||||
alert('host clicked ' + h);
|
||||
}
|
||||
render() {
|
||||
let { sources, isLoading, error } = this.state;
|
||||
if (error) {
|
||||
return <p>{error.message}</p>;
|
||||
}
|
||||
if (isLoading) {
|
||||
return <Spinner animation="border" variant="primary" />;
|
||||
}
|
||||
|
||||
let uniqueHosts = sources.reduce((a, d) => {
|
||||
if (!a.includes(d.source.host)) { a.push(d.source.host); }
|
||||
return a;
|
||||
}, []);
|
||||
|
||||
uniqueHosts.sort();
|
||||
|
||||
let uniqueUsers = sources.reduce((a, d) => {
|
||||
if (!a.includes(d.source.userName)) { a.push(d.source.userName); }
|
||||
return a;
|
||||
}, []);
|
||||
|
||||
uniqueUsers.sort();
|
||||
|
||||
if (this.state.selectedHost !== allHosts) {
|
||||
sources = sources.filter(x => x.source.host === this.state.selectedHost);
|
||||
};
|
||||
|
||||
if (this.state.selectedUser !== allUsers) {
|
||||
sources = sources.filter(x => x.source.userName === this.state.selectedUser);
|
||||
};
|
||||
|
||||
const columns = [{
|
||||
id: 'host',
|
||||
Header: 'Host',
|
||||
accessor: 'source.host',
|
||||
width: 150,
|
||||
}, {
|
||||
id: 'user',
|
||||
Header: 'User',
|
||||
accessor: 'source.userName',
|
||||
width: 150,
|
||||
}, {
|
||||
id: 'path',
|
||||
Header: 'Path',
|
||||
accessor: x => x.source,
|
||||
Cell: x => <Link to={'/snapshots/single-source?userName=' + x.value.userName + '&host=' + x.value.host + '&path=' + x.value.path}>{x.value.path}</Link>,
|
||||
width: 600,
|
||||
}, {
|
||||
id: 'lastSnapshotTime',
|
||||
Header: 'Last Snapshot',
|
||||
width: 200,
|
||||
accessor: x => x.lastSnapshotTime,
|
||||
}, {
|
||||
id: 'lastSnapshotSize',
|
||||
Header: 'Size',
|
||||
width: 100,
|
||||
accessor: x => x.lastSnapshotSize,
|
||||
}]
|
||||
|
||||
return <div>
|
||||
<Row>
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="primary" id="dropdown-basic">
|
||||
Host: {this.state.selectedHost}
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => this.selectHost(allHosts)}>(all)</Dropdown.Item>
|
||||
{uniqueHosts.map(v => <Dropdown.Item onClick={() => this.selectHost(v)}>{v}</Dropdown.Item>)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
|
||||
<Dropdown>
|
||||
<Dropdown.Toggle variant="primary" id="dropdown-basic">
|
||||
User: {this.state.selectedUser}
|
||||
</Dropdown.Toggle>
|
||||
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item onClick={() => this.selectUser(allUsers)}>(all)</Dropdown.Item>
|
||||
{uniqueUsers.map(v => <Dropdown.Item onClick={() => this.selectUser(v)}>{v}</Dropdown.Item>)}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</Row>
|
||||
|
||||
<p></p>
|
||||
<Row><ReactTable data={sources} columns={columns} /></Row>
|
||||
</div>;
|
||||
|
||||
}
|
||||
}
|
||||
13
htmlui/src/index.css
Normal file
13
htmlui/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
6
htmlui/src/index.js
Normal file
6
htmlui/src/index.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
139
htmlui/src/kopia-flat.svg
Normal file
139
htmlui/src/kopia-flat.svg
Normal file
@@ -0,0 +1,139 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
xmlns:osb="http://www.openswatchbook.org/uri/2009/osb"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
width="48"
|
||||
height="31"
|
||||
viewBox="0 0 12.700008 8.2020885"
|
||||
version="1.1"
|
||||
id="svg4919"
|
||||
inkscape:version="1.0beta1 (32d4812, 2019-09-19)"
|
||||
sodipodi:docname="kopia-flat.svg"
|
||||
inkscape:export-filename="/Users/jarek/Projects/kopia-ui/resources/icon.png"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96">
|
||||
<defs
|
||||
id="defs4913">
|
||||
<linearGradient
|
||||
osb:paint="gradient"
|
||||
id="linearGradient4578">
|
||||
<stop
|
||||
id="stop4574"
|
||||
offset="0"
|
||||
style="stop-color:#000000;stop-opacity:1;" />
|
||||
<stop
|
||||
id="stop4576"
|
||||
offset="1"
|
||||
style="stop-color:#0066ff;stop-opacity:0;" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="linearGradient2345"
|
||||
osb:paint="gradient">
|
||||
<stop
|
||||
style="stop-color:#000000;stop-opacity:1;"
|
||||
offset="0"
|
||||
id="stop2341" />
|
||||
<stop
|
||||
style="stop-color:#0066ff;stop-opacity:0;"
|
||||
offset="1"
|
||||
id="stop2343" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<sodipodi:namedview
|
||||
inkscape:pagecheckerboard="false"
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="5.6571604"
|
||||
inkscape:cx="53.451757"
|
||||
inkscape:cy="30.205905"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
inkscape:window-width="1440"
|
||||
inkscape:window-height="795"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="23"
|
||||
inkscape:window-maximized="0"
|
||||
units="px"
|
||||
width="64px"
|
||||
showguides="false"
|
||||
inkscape:snap-text-baseline="true"
|
||||
inkscape:document-rotation="0" />
|
||||
<metadata
|
||||
id="metadata4916">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
<dc:title></dc:title>
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(0,-280.06662)">
|
||||
<g
|
||||
id="g849"
|
||||
transform="matrix(0.04649709,0,0,0.04649709,-0.02134367,277.52719)"
|
||||
inkscape:export-xdpi="65.472687"
|
||||
inkscape:export-ydpi="65.472687">
|
||||
<g
|
||||
inkscape:export-ydpi="9.5134048"
|
||||
inkscape:export-xdpi="9.5134048"
|
||||
inkscape:export-filename="/Users/jarek/Projects/kopia-app/icons/converted/g886.png"
|
||||
style="fill:#2a7fff;fill-opacity:1;stroke:none;stroke-width:0.57724;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:0"
|
||||
transform="matrix(19.66121,0,0,20.144513,53.912845,-5697.4527)"
|
||||
id="g886">
|
||||
<path
|
||||
style="fill:#2a7fff;fill-opacity:1;stroke:none;stroke-width:0.365129;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
d="m 3.68292,285.81098 a 3.7143042,3.9255907 0 0 1 3.2394206,2.00885 2.978621,3.1480586 0 0 1 0.9055101,-0.15021 2.978621,3.1480586 0 0 1 2.9786373,3.14795 2.978621,3.1480586 0 0 1 -2.6197318,3.12262 v 0.0254 H 7.8278507 0.29155891 v -8.5e-4 a 2.3864853,2.5222394 0 0 1 -0.31315577,0.0246 2.3864853,2.5222394 0 0 1 -2.38640124,-2.52228 2.3864853,2.5222394 0 0 1 2.38640124,-2.52228 2.3864853,2.5222394 0 0 1 0.07019771,0.002 3.7143042,3.9255907 0 0 1 3.63432355,-3.136 z"
|
||||
id="rect4799-3"
|
||||
inkscape:connector-curvature="0"
|
||||
inkscape:export-xdpi="96"
|
||||
inkscape:export-ydpi="96" />
|
||||
</g>
|
||||
<g
|
||||
inkscape:export-filename="/Users/jarek/Projects/kopia-app/icons/converted/g886.png"
|
||||
inkscape:export-ydpi="9.5134048"
|
||||
inkscape:export-xdpi="9.5134048"
|
||||
style="fill:#e4e4e4;fill-opacity:1;stroke:none;stroke-width:0.280131;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="g10"
|
||||
transform="matrix(2.1802385,2.8772027,-1.7541947,3.575994,104.54534,-155.29438)">
|
||||
<g
|
||||
style="fill:#e4e4e4;fill-opacity:1;stroke:none;stroke-width:0.280131;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
|
||||
id="g883">
|
||||
<path
|
||||
inkscape:connector-curvature="0"
|
||||
d="m 52.475497,57.904193 2.118969,-4.700554 c 0.258337,-0.573742 0.794452,-1.452559 1.188918,-1.952342 l 2.189741,-5.099694 c 0.171725,-0.02042 0.344927,-0.09102 0.466732,-0.194079 l 0.630265,-0.546749 c 0.243632,-0.206882 0.608139,-0.67108 0.736916,-0.95794 l 0.31965,-0.665782 c 0.05776,-0.130508 0.08615,-0.298735 0.08241,-0.463714 l 4.176599,-5.28537 c 0.206096,0.05958 0.452753,0.01539 0.625077,-0.131566 l 0.268535,-0.229977 c 0.242872,-0.207632 0.555873,-0.62096 0.686215,-0.907865 l 0.122894,-0.272803 c 0.08361,-0.186032 0.07645,-0.420341 0.0074,-0.621993 L 81.942439,16.980848 c 0.394443,-0.499011 -3.499736,2.827825 -4.003591,3.221242 L 63.158733,32.961034 c 0,0 -0.470077,-0.09048 -0.672002,2.42e-4 l -0.315191,0.142618 c -0.289847,0.131081 -0.692958,0.446742 -0.889398,0.696224 l -0.197198,0.248733 c -0.137168,0.173703 -0.173217,0.41621 -0.114352,0.619702 l -5.260067,4.10647 c -0.211006,-0.0255 -0.444855,0.0083 -0.614306,0.0842 l -0.765391,0.385589 c -0.289846,0.131081 -0.709835,0.462661 -0.906298,0.712915 l -0.479332,0.595593 c -0.106358,0.135778 -0.173913,0.333686 -0.180977,0.520579 l -4.899926,2.25652 c -0.503833,0.392646 -1.390045,0.927547 -1.970452,1.187416 l -5.715688,2.569121 c -0.579625,0.259846 -0.824906,1.000611 -0.618168,1.569324 0,0 0.927303,2.582054 3.611972,5.535017 l -2.71371,2.679373 c -0.644457,-0.484249 -0.865773,-0.265673 -1.130524,-0.0049 l -0.05151,0.05087 c -0.264752,0.260725 -0.48609,0.480073 0.0055,1.114567 l -3.777657,3.730903 c -0.643676,-0.484272 -0.865751,-0.266445 -1.129765,-0.0042 l -0.05148,0.0501 c -0.264751,0.260725 -0.485307,0.480051 0.0055,1.11534 L 22.80456,74.18879 c -0.113497,0.112842 1.950106,1.40756 1.950106,1.40756 l 11.27516,-10.990626 c 0.645262,0.483455 0.865773,0.265673 1.129742,0.005 l 0.05148,-0.0501 c 0.264774,-0.261497 0.486089,-0.480073 -0.0054,-1.116112 l 3.777612,-3.729358 c 0.643676,0.484272 0.865796,0.264901 1.129765,0.0042 l 0.05148,-0.0501 c 0.264797,-0.262269 0.48609,-0.480074 -0.0055,-1.11534 l 2.723413,-2.690456 c 3.01624,2.625495 6.011328,2.658358 6.011328,2.658358 0.577766,0.201527 1.322586,-0.04231 1.581751,-0.617623 z"
|
||||
id="path8"
|
||||
sodipodi:nodetypes="ccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
|
||||
style="fill:#e4e4e4;fill-opacity:1;stroke:none;stroke-width:0.386847;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
<ellipse
|
||||
inkscape:export-ydpi="9.5134048"
|
||||
inkscape:export-xdpi="9.5134048"
|
||||
inkscape:export-filename="/Users/jarek/Projects/kopia-app/icons/converted/g886.png"
|
||||
ry="1.5360007"
|
||||
rx="1.6261826"
|
||||
cy="192.16341"
|
||||
cx="190.19328"
|
||||
id="path876"
|
||||
style="fill:#b3b3b3;fill-opacity:1;stroke:#00ff00;stroke-width:0.179274;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 7.5 KiB |
66
htmlui/src/uiutil.js
Normal file
66
htmlui/src/uiutil.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export function sourceDisplayName(s) {
|
||||
if (!s.host && !s.userName) {
|
||||
return "(all)"
|
||||
}
|
||||
|
||||
if (!s.userName) {
|
||||
return "(all)@" + s.host;
|
||||
}
|
||||
|
||||
return s.userName + "@" + s.host + ":" + s.path;
|
||||
}
|
||||
|
||||
const base10UnitPrefixes = ["", "K", "M", "G", "T"];
|
||||
|
||||
function niceNumber(f) {
|
||||
return (Math.round(f * 10) / 10.0) + '';
|
||||
}
|
||||
|
||||
function toDecimalUnitString(f, thousand, prefixes, suffix) {
|
||||
for (var i = 0; i < prefixes.length; i++) {
|
||||
if (f < 0.9*thousand) {
|
||||
return niceNumber(f) + ' ' + prefixes[i] + suffix;
|
||||
}
|
||||
f /= thousand
|
||||
}
|
||||
|
||||
return niceNumber(f) + ' ' + prefixes[prefixes.length - 1] + suffix;
|
||||
}
|
||||
|
||||
|
||||
export function sizeDisplayName(s) {
|
||||
return toDecimalUnitString(s, 1000, base10UnitPrefixes, "B");
|
||||
}
|
||||
|
||||
export function intervalDisplayName(v) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
export function timesOfDayDisplayName(v) {
|
||||
if (!v) {
|
||||
return "(none)";
|
||||
}
|
||||
return v.length + " times";
|
||||
}
|
||||
|
||||
export function parseQuery(queryString) {
|
||||
var query = {};
|
||||
var pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
|
||||
for (var i = 0; i < pairs.length; i++) {
|
||||
var pair = pairs[i].split('=');
|
||||
query[decodeURIComponent(pair[0])] = decodeURIComponent(pair[1] || '');
|
||||
}
|
||||
return query;
|
||||
}
|
||||
|
||||
export function rfc3339TimestampDisplayName(n) {
|
||||
let t = new Date(n);
|
||||
return t.toLocaleString();
|
||||
}
|
||||
|
||||
export function objectLink(n) {
|
||||
if (n.startsWith("k")) {
|
||||
return "/snapshots/dir/" + n;
|
||||
}
|
||||
return "/snapshots/file/" + n;
|
||||
}
|
||||
@@ -4,7 +4,6 @@
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kopia/kopia/fs"
|
||||
@@ -70,7 +69,7 @@ func sourceMatchesURLFilter(src snapshot.SourceInfo, query url.Values) bool {
|
||||
if v := query.Get("userName"); v != "" && src.UserName != v {
|
||||
return false
|
||||
}
|
||||
if v := query.Get("path"); v != "" && !strings.Contains(src.Path, v) {
|
||||
if v := query.Get("path"); v != "" && src.Path != v {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
334
internal/server/htmlui_fallback.go
Normal file
334
internal/server/htmlui_fallback.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// Code generated for package server by go-bindata DO NOT EDIT. (@generated)
|
||||
// sources:
|
||||
// index.html
|
||||
// +build !embedhtml
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func bindataRead(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
clErr := gz.Close()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
if clErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
bytes []byte
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
type bindataFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// Name return file name
|
||||
func (fi bindataFileInfo) Name() string {
|
||||
return fi.name
|
||||
}
|
||||
|
||||
// Size return file size
|
||||
func (fi bindataFileInfo) Size() int64 {
|
||||
return fi.size
|
||||
}
|
||||
|
||||
// Mode return file mode
|
||||
func (fi bindataFileInfo) Mode() os.FileMode {
|
||||
return fi.mode
|
||||
}
|
||||
|
||||
// Mode return file modify time
|
||||
func (fi bindataFileInfo) ModTime() time.Time {
|
||||
return fi.modTime
|
||||
}
|
||||
|
||||
// IsDir return file whether a directory
|
||||
func (fi bindataFileInfo) IsDir() bool {
|
||||
return fi.mode&os.ModeDir != 0
|
||||
}
|
||||
|
||||
// Sys return file is sys mode
|
||||
func (fi bindataFileInfo) Sys() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
type assetFile struct {
|
||||
*bytes.Reader
|
||||
name string
|
||||
childInfos []os.FileInfo
|
||||
childInfoOffset int
|
||||
}
|
||||
|
||||
type assetOperator struct{}
|
||||
|
||||
// Open implement http.FileSystem interface
|
||||
func (f *assetOperator) Open(name string) (http.File, error) {
|
||||
var err error
|
||||
if len(name) > 0 && name[0] == '/' {
|
||||
name = name[1:]
|
||||
}
|
||||
content, err := Asset(name)
|
||||
if err == nil {
|
||||
return &assetFile{name: name, Reader: bytes.NewReader(content)}, nil
|
||||
}
|
||||
children, err := AssetDir(name)
|
||||
if err == nil {
|
||||
childInfos := make([]os.FileInfo, 0, len(children))
|
||||
for _, child := range children {
|
||||
childPath := filepath.Join(name, child)
|
||||
info, errInfo := AssetInfo(filepath.Join(name, child))
|
||||
if errInfo == nil {
|
||||
childInfos = append(childInfos, info)
|
||||
} else {
|
||||
childInfos = append(childInfos, newDirFileInfo(childPath))
|
||||
}
|
||||
}
|
||||
return &assetFile{name: name, childInfos: childInfos}, nil
|
||||
} else {
|
||||
// If the error is not found, return an error that will
|
||||
// result in a 404 error. Otherwise the server returns
|
||||
// a 500 error for files not found.
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Close no need do anything
|
||||
func (f *assetFile) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Readdir read dir's children file info
|
||||
func (f *assetFile) Readdir(count int) ([]os.FileInfo, error) {
|
||||
if len(f.childInfos) == 0 {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
if count <= 0 {
|
||||
return f.childInfos, nil
|
||||
}
|
||||
if f.childInfoOffset+count > len(f.childInfos) {
|
||||
count = len(f.childInfos) - f.childInfoOffset
|
||||
}
|
||||
offset := f.childInfoOffset
|
||||
f.childInfoOffset += count
|
||||
return f.childInfos[offset : offset+count], nil
|
||||
}
|
||||
|
||||
// Stat read file info from asset item
|
||||
func (f *assetFile) Stat() (os.FileInfo, error) {
|
||||
if len(f.childInfos) != 0 {
|
||||
return newDirFileInfo(f.name), nil
|
||||
}
|
||||
return AssetInfo(f.name)
|
||||
}
|
||||
|
||||
// newDirFileInfo return default dir file info
|
||||
func newDirFileInfo(name string) os.FileInfo {
|
||||
return &bindataFileInfo{
|
||||
name: name,
|
||||
size: 0,
|
||||
mode: os.FileMode(2147484068), // equal os.FileMode(0644)|os.ModeDir
|
||||
modTime: time.Time{}}
|
||||
}
|
||||
|
||||
// AssetFile return a http.FileSystem instance that data backend by asset
|
||||
func AssetFile() http.FileSystem {
|
||||
return &assetOperator{}
|
||||
}
|
||||
|
||||
var _indexHtml = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\x74\x90\x3b\x4f\xfc\x30\x10\xc4\xfb\xff\xa7\x98\xff\x09\x5d\x45\xe2\xfe\x64\xd2\x00\x05\xa2\x80\x02\x84\x28\x37\xf1\x26\xb6\xce\x2f\xd9\xeb\x82\x6f\x8f\x92\x9c\xc4\x43\xa2\xf1\x43\x3b\xbf\x19\xed\xe8\xff\x77\x4f\xb7\x2f\xef\xcf\xf7\xb0\x12\xfc\xf0\x4f\xef\x17\x00\x68\xcb\x64\xf6\xe7\xf6\x15\x27\x9e\x87\xc7\x94\x1d\xe1\xf5\x01\x31\x09\x6a\xcb\x39\x15\x61\xd3\x6b\xb5\x8f\x77\x52\x7d\xa1\x7a\x4c\xe6\xe3\x9b\x4b\xfe\xc3\xe1\x1a\xad\x32\x28\x42\x13\x6c\xe1\xf9\xe6\x60\x45\x72\x3d\x29\xb5\x38\xb1\x6d\xec\xa7\x14\xd4\x79\x45\x2f\x67\x61\xcf\x54\xb9\x1e\x86\x34\xcf\x6e\x72\xe4\x31\x36\xe7\x8d\x56\x34\xf4\x5a\xe5\x1f\x99\x6f\x96\x23\xa6\x14\xb2\xf3\x2e\x2e\x98\x4b\x0a\xa8\xa9\x95\x89\xd7\xd8\xd3\x2f\x79\xe1\xe1\x0a\x81\xce\xbc\x95\xd2\x35\xd7\x8d\x2e\x1a\x12\xc2\xf1\x88\x25\xc1\xc5\x2a\xe4\x3d\x3a\xa1\xa5\x82\xc3\xc8\x66\x15\x6a\xb5\x92\x97\x06\xf6\xb5\xb5\xda\xfa\xfc\x0c\x00\x00\xff\xff\xe9\x99\x45\x19\x66\x01\x00\x00")
|
||||
|
||||
func indexHtmlBytes() ([]byte, error) {
|
||||
return bindataRead(
|
||||
_indexHtml,
|
||||
"index.html",
|
||||
)
|
||||
}
|
||||
|
||||
func indexHtml() (*asset, error) {
|
||||
bytes, err := indexHtmlBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "index.html", size: 358, mode: os.FileMode(420), modTime: time.Unix(1574253768, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
a, err := f()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
|
||||
}
|
||||
return a.bytes, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
|
||||
// MustAsset is like Asset but panics when Asset would return an error.
|
||||
// It simplifies safe initialization of global variables.
|
||||
func MustAsset(name string) []byte {
|
||||
a, err := Asset(name)
|
||||
if err != nil {
|
||||
panic("asset: Asset(" + name + "): " + err.Error())
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
// AssetInfo loads and returns the asset info for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func AssetInfo(name string) (os.FileInfo, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
a, err := f()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
|
||||
}
|
||||
return a.info, nil
|
||||
}
|
||||
return nil, fmt.Errorf("AssetInfo %s not found", name)
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() (*asset, error){
|
||||
"index.html": indexHtml,
|
||||
}
|
||||
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for childName := range node.Children {
|
||||
rv = append(rv, childName)
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
type bintree struct {
|
||||
Func func() (*asset, error)
|
||||
Children map[string]*bintree
|
||||
}
|
||||
|
||||
var _bintree = &bintree{nil, map[string]*bintree{
|
||||
"index.html": &bintree{indexHtml, map[string]*bintree{}},
|
||||
}}
|
||||
|
||||
// RestoreAsset restores an asset under the given directory
|
||||
func RestoreAsset(dir, name string) error {
|
||||
data, err := Asset(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := AssetInfo(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreAssets restores an asset under the given directory recursively
|
||||
func RestoreAssets(dir, name string) error {
|
||||
children, err := AssetDir(name)
|
||||
// File
|
||||
if err != nil {
|
||||
return RestoreAsset(dir, name)
|
||||
}
|
||||
// Dir
|
||||
for _, child := range children {
|
||||
err = RestoreAssets(dir, filepath.Join(name, child))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func _filePath(dir, name string) string {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
|
||||
}
|
||||
11
internal/server/index.html
Normal file
11
internal/server/index.html
Normal file
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Kopia UI not supported.</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>Kopia UI not supported, use an <a href="https://github.com/kopia/kopia/releases">official build</a>.</p>
|
||||
<p>When compiling from source use:</p>
|
||||
<pre>$ make html-ui-bindata && go install -tags embedhtml</pre>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,44 +1,23 @@
|
||||
HUGO_VERSION=0.59.1
|
||||
# don't put tools under current directory, otherwise 'make server' fails because there are too
|
||||
# many open files due to 'node_modules'
|
||||
TOOLS_DIR=$(CURDIR)/../.tools
|
||||
NPM_TOOL=$(TOOLS_DIR)/nodejs/node/bin/npm
|
||||
WATCH=true
|
||||
TOOL_PATH=$(PATH):$(TOOLS_DIR)/nodejs/node/bin
|
||||
|
||||
HUGO_TOOL=$(TOOLS_DIR)/hugo/hugo
|
||||
all: build
|
||||
|
||||
uname := $(shell uname -s)
|
||||
include ../tools/tools.mk
|
||||
|
||||
build: gen-cli-reference-pages $(HUGO_TOOL) module-deps
|
||||
build: gen-cli-reference-pages $(HUGO_TOOL) node_modules
|
||||
PATH=$(TOOL_PATH) $(HUGO_TOOL)
|
||||
|
||||
server: $(HUGO_TOOL)
|
||||
PATH=$(TOOL_PATH) $(HUGO_TOOL) server --watch=$(WATCH)
|
||||
|
||||
module-deps: node_modules
|
||||
|
||||
node_modules: $(NPM_TOOL)
|
||||
PATH=$(TOOL_PATH) $(NPM_TOOL) install
|
||||
|
||||
$(HUGO_TOOL):
|
||||
make install-tools
|
||||
|
||||
$(NPM_TOOL):
|
||||
make -C .. install-webtools
|
||||
|
||||
clean:
|
||||
rm -rf public/ resources/ node_modules/ $(TOOLS_DIR)/
|
||||
|
||||
install-tools:
|
||||
mkdir -p $(TOOLS_DIR)/hugo
|
||||
|
||||
ifeq ($(uname),Linux)
|
||||
curl -LsS https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_extended_$(HUGO_VERSION)_Linux-64bit.tar.gz | tar zxv -C $(TOOLS_DIR)/hugo
|
||||
else
|
||||
curl -LsS https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_extended_$(HUGO_VERSION)_macOS-64bit.tar.gz | tar zxv -C $(TOOLS_DIR)/hugo
|
||||
endif
|
||||
|
||||
gen-cli-reference-pages:
|
||||
rm -rf content/docs/Reference/Command-Line/Advanced
|
||||
rm -rf content/docs/Reference/Command-Line/Common
|
||||
|
||||
55
tools/tools.mk
Normal file
55
tools/tools.mk
Normal file
@@ -0,0 +1,55 @@
|
||||
SELF_DIR := $(realpath $(dir $(lastword $(MAKEFILE_LIST))))
|
||||
TOOLS_DIR:=$(SELF_DIR)/.tools
|
||||
uname := $(shell uname -s)
|
||||
|
||||
# tool versions
|
||||
GOLANGCI_LINT_VERSION=v1.18.0
|
||||
NODE_VERSION=12.13.0
|
||||
HUGO_VERSION=0.59.1
|
||||
|
||||
# goveralls
|
||||
GOVERALLS_TOOL=$(TOOLS_DIR)/bin/goveralls
|
||||
|
||||
$(GOVERALLS_TOOL):
|
||||
mkdir -p $(TOOLS_DIR)
|
||||
GO111MODULE=off GOPATH=$(TOOLS_DIR) go get github.com/mattn/goveralls
|
||||
|
||||
# nodejs / npm
|
||||
NPM_TOOL=$(TOOLS_DIR)/nodejs/node/bin/npm
|
||||
TOOL_PATH=$(PATH):$(TOOLS_DIR)/nodejs/node/bin
|
||||
|
||||
$(NPM_TOOL):
|
||||
echo SELF_DIR: $(SELF_DIR)
|
||||
mkdir -p $(TOOLS_DIR)/nodejs
|
||||
|
||||
ifeq ($(uname),Linux)
|
||||
curl -LsS https://nodejs.org/dist/v$(NODE_VERSION)/node-v$(NODE_VERSION)-linux-x64.tar.gz | tar zx -C $(TOOLS_DIR)/nodejs
|
||||
else
|
||||
curl -LsS https://nodejs.org/dist/v$(NODE_VERSION)/node-v$(NODE_VERSION)-darwin-x64.tar.gz | tar zx -C $(TOOLS_DIR)/nodejs
|
||||
endif
|
||||
mv $(TOOLS_DIR)/nodejs/node-v$(NODE_VERSION)* $(TOOLS_DIR)/nodejs/node/
|
||||
|
||||
BINDATA_TOOL=$(TOOLS_DIR)/go-bindata
|
||||
|
||||
$(BINDATA_TOOL):
|
||||
go build -o $(BINDATA_TOOL) github.com/go-bindata/go-bindata/go-bindata
|
||||
|
||||
# linter
|
||||
LINTER_TOOL=$(TOOLS_DIR)/bin/golangci-lint
|
||||
|
||||
$(LINTER_TOOL):
|
||||
mkdir -p $(TOOLS_DIR)
|
||||
curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b $(TOOLS_DIR)/bin/ $(GOLANGCI_LINT_VERSION)
|
||||
|
||||
# hugo
|
||||
HUGO_TOOL=$(TOOLS_DIR)/hugo/hugo
|
||||
|
||||
$(HUGO_TOOL):
|
||||
mkdir -p $(TOOLS_DIR)/hugo
|
||||
|
||||
ifeq ($(uname),Linux)
|
||||
curl -LsS https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_extended_$(HUGO_VERSION)_Linux-64bit.tar.gz | tar zxv -C $(TOOLS_DIR)/hugo
|
||||
else
|
||||
curl -LsS https://github.com/gohugoio/hugo/releases/download/v$(HUGO_VERSION)/hugo_extended_$(HUGO_VERSION)_macOS-64bit.tar.gz | tar zxv -C $(TOOLS_DIR)/hugo
|
||||
endif
|
||||
|
||||
Reference in New Issue
Block a user